diff --git a/test/javascripts/wizard/test_helper.js b/test/javascripts/wizard/test_helper.js
new file mode 100644
index 00000000..7d851f1b
--- /dev/null
+++ b/test/javascripts/wizard/test_helper.js
@@ -0,0 +1,74 @@
+// discourse-skip-module
+/*global document, Logster, QUnit */
+
+window.Discourse = {};
+window.Wizard = {};
+Wizard.SiteSettings = {};
+Discourse.__widget_helpers = {};
+Discourse.SiteSettings = Wizard.SiteSettings;
+
+//= require env
+//= require jquery.debug
+//= require ember.debug
+//= require locales/i18n
+//= require locales/en
+//= require route-recognizer
+//= require fake_xml_http_request
+//= require pretender
+//= require qunit
+//= require ember-qunit
+//= require discourse-loader
+//= require jquery.debug
+//= require handlebars
+//= require ember-template-compiler
+//= require wizard-application
+//= require wizard-vendor
+//= require_tree ./helpers
+//= require_tree ./acceptance
+//= require_tree ./models
+//= require_tree ./components
+//= require ./wizard-pretender
+//= require test-shims
+
+document.addEventListener("DOMContentLoaded", function () {
+ document.body.insertAdjacentHTML(
+ "afterbegin",
+ `
+
+
+ `
+ );
+});
+
+if (window.Logster) {
+ Logster.enabled = false;
+} else {
+ window.Logster = { enabled: false };
+}
+Ember.Test.adapter = window.QUnitAdapter.create();
+
+let createPretendServer = requirejs(
+ "wizard/test/wizard-pretender",
+ null,
+ null,
+ false
+).default;
+
+let server;
+QUnit.testStart(function () {
+ server = createPretendServer();
+});
+
+QUnit.testDone(function () {
+ server.shutdown();
+});
+
+let _testApp = requirejs("wizard/test/helpers/start-app").default();
+let _buildResolver = requirejs("discourse-common/resolver").buildResolver;
+window.setResolver(_buildResolver("wizard").create({ namespace: _testApp }));
+
+Object.keys(requirejs.entries).forEach(function (entry) {
+ if (/\-test/.test(entry)) {
+ requirejs(entry, null, null, true);
+ }
+});
diff --git a/test/javascripts/wizard/wizard-pretender.js b/test/javascripts/wizard/wizard-pretender.js
new file mode 100644
index 00000000..e9dccfb3
--- /dev/null
+++ b/test/javascripts/wizard/wizard-pretender.js
@@ -0,0 +1,106 @@
+import Pretender from "pretender";
+
+// TODO: This file has some copied and pasted functions from `create-pretender` - would be good
+// to centralize that code at some point.
+
+function parsePostData(query) {
+ const result = {};
+ query.split("&").forEach(function (part) {
+ const item = part.split("=");
+ const firstSeg = decodeURIComponent(item[0]);
+ const m = /^([^\[]+)\[([^\]]+)\]/.exec(firstSeg);
+
+ const val = decodeURIComponent(item[1]).replace(/\+/g, " ");
+ if (m) {
+ result[m[1]] = result[m[1]] || {};
+ result[m[1]][m[2]] = val;
+ } else {
+ result[firstSeg] = val;
+ }
+ });
+ return result;
+}
+
+function response(code, obj) {
+ if (typeof code === "object") {
+ obj = code;
+ code = 200;
+ }
+ return [code, { "Content-Type": "application/json" }, obj];
+}
+
+export default function () {
+ const server = new Pretender(function () {
+ this.get("/wizard.json", () => {
+ return response(200, {
+ wizard: {
+ start: "hello-world",
+ completed: true,
+ steps: [
+ {
+ id: "hello-world",
+ title: "hello there",
+ index: 0,
+ description: "hello!",
+ fields: [
+ {
+ id: "full_name",
+ type: "text",
+ required: true,
+ description: "Your name",
+ },
+ ],
+ next: "second-step",
+ },
+ {
+ id: "second-step",
+ title: "Second step",
+ index: 1,
+ fields: [{ id: "some-title", type: "text" }],
+ previous: "hello-world",
+ next: "last-step",
+ },
+ {
+ id: "last-step",
+ index: 2,
+ fields: [
+ { id: "snack", type: "dropdown", required: true },
+ { id: "theme-preview", type: "component" },
+ { id: "an-image", type: "image" },
+ ],
+ previous: "second-step",
+ },
+ ],
+ },
+ });
+ });
+
+ this.put("/wizard/steps/:id", (request) => {
+ const body = parsePostData(request.requestBody);
+
+ if (body.fields.full_name === "Server Fail") {
+ return response(422, {
+ errors: [{ field: "full_name", description: "Invalid name" }],
+ });
+ } else {
+ return response(200, { success: true });
+ }
+ });
+ });
+
+ server.prepareBody = function (body) {
+ if (body && typeof body === "object") {
+ return JSON.stringify(body);
+ }
+ return body;
+ };
+
+ server.unhandledRequest = function (verb, path) {
+ const error =
+ "Unhandled request in test environment: " + path + " (" + verb + ")";
+ window.console.error(error);
+ throw error;
+ };
+
+ return server;
+}
diff --git a/test/javascripts/wizard/wizard-test.js b/test/javascripts/wizard/wizard-test.js
new file mode 100644
index 00000000..ea550cf2
--- /dev/null
+++ b/test/javascripts/wizard/wizard-test.js
@@ -0,0 +1,78 @@
+import { click, currentRouteName, fillIn, visit } from "@ember/test-helpers";
+import { module, test } from "qunit";
+import { run } from "@ember/runloop";
+import startApp from "wizard/test/helpers/start-app";
+
+let wizard;
+module("Acceptance: wizard", {
+ beforeEach() {
+ wizard = startApp();
+ },
+
+ afterEach() {
+ run(wizard, "destroy");
+ },
+});
+
+function exists(selector) {
+ return document.querySelector(selector) !== null;
+}
+
+test("Wizard starts", async function (assert) {
+ await visit("/");
+ assert.ok(exists(".wizard-column-contents"));
+ assert.strictEqual(currentRouteName(), "step");
+});
+
+test("Going back and forth in steps", async function (assert) {
+ await visit("/steps/hello-world");
+ assert.ok(exists(".wizard-step"));
+ assert.ok(
+ exists(".wizard-step-hello-world"),
+ "it adds a class for the step id"
+ );
+ assert.ok(!exists(".wizard-btn.finish"), "can’t finish on first step");
+ assert.ok(exists(".wizard-progress"));
+ assert.ok(exists(".wizard-step-title"));
+ assert.ok(exists(".wizard-step-description"));
+ assert.ok(
+ !exists(".invalid .field-full-name"),
+ "don't show it as invalid until the user does something"
+ );
+ assert.ok(exists(".wizard-field .field-description"));
+ assert.ok(!exists(".wizard-btn.back"));
+ assert.ok(!exists(".wizard-field .field-error-description"));
+
+ // invalid data
+ await click(".wizard-btn.next");
+ assert.ok(exists(".invalid .field-full-name"));
+
+ // server validation fail
+ await fillIn("input.field-full-name", "Server Fail");
+ await click(".wizard-btn.next");
+ assert.ok(exists(".invalid .field-full-name"));
+ assert.ok(exists(".wizard-field .field-error-description"));
+
+ // server validation ok
+ await fillIn("input.field-full-name", "Evil Trout");
+ await click(".wizard-btn.next");
+ assert.ok(!exists(".wizard-field .field-error-description"));
+ assert.ok(!exists(".wizard-step-description"));
+ assert.ok(
+ exists(".wizard-btn.finish"),
+ "shows finish on an intermediate step"
+ );
+
+ await click(".wizard-btn.next");
+ assert.ok(exists(".select-kit.field-snack"), "went to the next step");
+ assert.ok(exists(".preview-area"), "renders the component field");
+ assert.ok(exists(".wizard-btn.done"), "last step shows a done button");
+ assert.ok(exists(".action-link.back"), "shows the back button");
+ assert.ok(!exists(".wizard-step-title"));
+ assert.ok(!exists(".wizard-btn.finish"), "can’t finish on last step");
+
+ await click(".action-link.back");
+ assert.ok(exists(".wizard-step-title"));
+ assert.ok(exists(".wizard-btn.next"));
+ assert.ok(!exists(".wizard-prev"));
+});