Skip to content

Commit 945d503

Browse files
gwleclercclaude
andcommitted
test(e2e): add Playwright UI non-regression suite
21 framework-agnostic specs (tests/e2e/smocker.spec.ts) locking the observable behaviour of the web UI: shell, routing, links, all pages, navigation, the mock editor (open/cancel, prefill, create via form and raw YAML), lock/unlock, autorefresh, session new/select/reset/rename, and file import. Assertions target stable text / hrefs / URLs / SVG (never framework DOM), so the same suite passes against the pre-migration and post-migration UIs. Output goes under build/e2e. Requires @playwright/test (in devDependencies) and a running server pointed at via SMOCKER_E2E_URL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f89f963 commit 945d503

2 files changed

Lines changed: 314 additions & 0 deletions

File tree

tests/e2e/playwright.config.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
// Non-regression (TNR) E2E suite for the Smocker web UI. The same specs must pass against the
4+
// pre-migration UI (React 16 / antd 4 / Parcel) and the post-migration UI (React 19 / antd / Vite),
5+
// so assertions target stable, framework-agnostic content (visible text, data, URLs, SVG presence)
6+
// rather than antd-specific DOM. Point at a running server via SMOCKER_E2E_URL.
7+
export default defineConfig({
8+
testDir: ".",
9+
// Keep all generated output under the repo's build/ directory (never at the repo root).
10+
outputDir: "../../build/e2e/test-results",
11+
timeout: 30_000,
12+
expect: { timeout: 10_000 },
13+
fullyParallel: false,
14+
retries: 0,
15+
reporter: [["list"]],
16+
use: {
17+
baseURL: process.env.SMOCKER_E2E_URL || "http://localhost:18081",
18+
trace: "off",
19+
actionTimeout: 10_000,
20+
},
21+
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
22+
});

tests/e2e/smocker.spec.ts

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
// Non-regression (TNR) suite locking the OBSERVABLE FEATURES of the Smocker web UI against a
4+
// server seeded with tests/sessions (session "Session #1" id 3giPMr5IR, one mock GET /test ->
5+
// {"message":"test"} id YnJEM95SR, one matching history entry). Passing this suite on the new UI
6+
// means feature parity with the old one. Assertions target stable text / anchor hrefs / URLs /
7+
// SVG — never framework DOM or ARIA roles (antd 4 exposed nav items as role=link, antd 6 as
8+
// role=menuitem, but both render real <a href> anchors) — so they hold across the migration.
9+
//
10+
// Ordering: reads run before any session-creating test (a freshly-seeded server has exactly one
11+
// session, shown by default); mutating tests come last, destructive "Reset" is very last. Serial.
12+
13+
const MOCK_ID = "YnJEM95SR";
14+
const HISTORY = "/pages/history";
15+
const MOCKS = "/pages/mocks";
16+
17+
const intro = {
18+
history:
19+
"This is the history of the requests made during the selected session.",
20+
mocks: "This is the list of declared mocks ordered by priority.",
21+
visualize: "This is a graphical representation of call history.",
22+
};
23+
24+
test.describe.configure({ mode: "serial" });
25+
26+
// --- Shell & routing -------------------------------------------------------------------------
27+
28+
test("app shell: navbar, sidebar, seeded session, no page errors", async ({ page }) => {
29+
const errors: string[] = [];
30+
page.on("pageerror", (e) => errors.push(e.message));
31+
32+
await page.goto("/");
33+
await expect(page).toHaveTitle(/Smocker/);
34+
await expect(page.getByText("Documentation")).toBeVisible();
35+
await expect(page.getByText("Session #1")).toBeVisible();
36+
await expect(page.getByText("New Session")).toBeVisible();
37+
await expect(page.getByText("Reset Sessions")).toBeVisible();
38+
39+
expect(errors, `page errors: ${errors.join(" | ")}`).toEqual([]);
40+
});
41+
42+
test("root redirects to history with a session selected", async ({ page }) => {
43+
await page.goto("/");
44+
await expect(page).toHaveURL(/\/pages\/history/);
45+
await expect(page).toHaveURL(/session=/);
46+
});
47+
48+
// --- Links -----------------------------------------------------------------------------------
49+
50+
test("navbar & footer links point to the right targets", async ({ page }) => {
51+
await page.goto(HISTORY);
52+
// Navbar nav anchors (nav href carries a ?session= query).
53+
await expect(page.locator('a[href^="/pages/history?"]').first()).toBeVisible();
54+
await expect(page.locator('a[href^="/pages/mocks?"]').first()).toBeVisible();
55+
// External links open in a new tab.
56+
await expect(
57+
page.locator('a[href="https://smocker.dev/"]').first(),
58+
).toHaveAttribute("target", "_blank");
59+
await expect(
60+
page.locator('a[href="https://github.com/smocker-dev/smocker"]').first(),
61+
).toHaveAttribute("target", "_blank");
62+
await expect(page.locator('a[href="https://smocker.dev"]').first()).toBeVisible();
63+
});
64+
65+
test("history entry deep links: 'Matched Mock' and 'Create a new mock from entry'", async ({
66+
page,
67+
}) => {
68+
await page.goto(HISTORY);
69+
await expect(page.locator(`a[href="/pages/mocks/${MOCK_ID}"]`).first()).toBeVisible();
70+
await expect(page.locator('a[href="/pages/mocks"]').first()).toBeVisible();
71+
});
72+
73+
// --- Page content (features) -----------------------------------------------------------------
74+
75+
test("history page shows the seeded call", async ({ page }) => {
76+
await page.goto(HISTORY);
77+
await expect(page.getByText(intro.history)).toBeVisible();
78+
await expect(page.getByText("/test", { exact: true }).first()).toBeVisible();
79+
await expect(page.getByText("Matched Mock").first()).toBeVisible();
80+
await expect(page.getByText("Copy as curl").first()).toBeVisible();
81+
await expect(page.getByText("Autorefresh").first()).toBeVisible();
82+
});
83+
84+
test("mocks page shows the seeded mock", async ({ page }) => {
85+
await page.goto(MOCKS);
86+
await expect(page.getByText(intro.mocks)).toBeVisible();
87+
await expect(page.getByText(new RegExp(MOCK_ID)).first()).toBeVisible();
88+
await expect(page.getByText("Add Mocks").first()).toBeVisible();
89+
});
90+
91+
test("visualize page renders the sequence diagram", async ({ page }) => {
92+
await page.goto("/pages/visualize");
93+
await expect(page.getByText(intro.visualize)).toBeVisible();
94+
await expect(page.getByText("Diagram of calls")).toBeVisible();
95+
await expect(page.locator("svg").first()).toBeVisible();
96+
});
97+
98+
// --- Navigation & interactions ---------------------------------------------------------------
99+
100+
test("navbar navigates between History and Mocks", async ({ page }) => {
101+
await page.goto(HISTORY);
102+
103+
await page.locator('a[href^="/pages/mocks?"]').first().click();
104+
await expect(page).toHaveURL(/\/pages\/mocks/);
105+
await expect(page.getByText(intro.mocks)).toBeVisible();
106+
107+
await page.locator('a[href^="/pages/history?"]').first().click();
108+
await expect(page).toHaveURL(/\/pages\/history/);
109+
await expect(page.getByText(intro.history)).toBeVisible();
110+
});
111+
112+
test("history page 'Visualize' link opens the diagram", async ({ page }) => {
113+
await page.goto(HISTORY);
114+
await page.locator('a[href^="/pages/visualize"]').first().click();
115+
await expect(page).toHaveURL(/\/pages\/visualize/);
116+
await expect(page.getByText(intro.visualize)).toBeVisible();
117+
});
118+
119+
test("'Matched Mock' link opens the mock detail route", async ({ page }) => {
120+
await page.goto(HISTORY);
121+
await page.locator(`a[href="/pages/mocks/${MOCK_ID}"]`).first().click();
122+
await expect(page).toHaveURL(new RegExp(`/pages/mocks/${MOCK_ID}`));
123+
await expect(page.getByText(new RegExp(MOCK_ID)).first()).toBeVisible();
124+
});
125+
126+
test("'Add Mocks' opens the editor with Save/Cancel and closes on Cancel", async ({ page }) => {
127+
await page.goto(MOCKS);
128+
await page.getByRole("button", { name: "Add Mocks" }).click();
129+
await expect(page.getByRole("button", { name: "Save" })).toBeVisible();
130+
const cancel = page.getByRole("button", { name: "Cancel" });
131+
await expect(cancel).toBeVisible();
132+
await cancel.click();
133+
await expect(page.getByRole("button", { name: "Save" })).toHaveCount(0);
134+
});
135+
136+
test("'Create a new mock from entry' opens the prefilled editor", async ({ page }) => {
137+
await page.goto(HISTORY);
138+
// Wait for the automatic session selection to settle (?session in the URL) and the entry to
139+
// render before clicking, so the create-from-entry navigation isn't clobbered by the
140+
// session-selection redirect and reliably carries the prefilled editor state.
141+
await expect(page).toHaveURL(/session=/);
142+
await expect(page.getByText(intro.history)).toBeVisible();
143+
await page.locator('a[href="/pages/mocks"]').first().click();
144+
await expect(page).toHaveURL(/\/pages\/mocks/);
145+
await expect(page.getByRole("button", { name: "Save" })).toBeVisible();
146+
});
147+
148+
// --- Mock creation (mutations) ---------------------------------------------------------------
149+
150+
// Whether we run against the legacy (Parcel/antd4/CodeMirror5) build. Set by the TNR gate.
151+
const LEGACY = process.env.SMOCKER_E2E_VARIANT === "old";
152+
153+
test("create a mock via the Visual Editor form", async ({ page }) => {
154+
await page.goto(MOCKS);
155+
await page.getByRole("button", { name: "Add Mocks" }).click();
156+
// The Visual Editor is the default tab; set a request path (method defaults to GET) and save.
157+
await page.locator("#request_path").fill("/tnr-form");
158+
await page.getByRole("button", { name: "Save" }).click();
159+
await expect(page.getByRole("button", { name: "Save" })).toHaveCount(0); // drawer closed
160+
await expect(page.getByText("/tnr-form").first()).toBeVisible(); // mock now listed
161+
});
162+
163+
test("create a mock via the Raw YAML editor", async ({ page }) => {
164+
// The raw editor is a CodeMirror instance; driving it differs between CM5 (legacy) and CM6.
165+
test.skip(LEGACY, "raw-editor driving is CodeMirror-version specific; covered on the new UI");
166+
await page.goto(MOCKS);
167+
await page.getByRole("button", { name: "Add Mocks" }).click();
168+
await page.getByRole("tab", { name: /Raw YAML/i }).click();
169+
const editor = page.locator('.cm-content[contenteditable="true"]:visible').first();
170+
await editor.click();
171+
await page.keyboard.press("ControlOrMeta+A");
172+
await page.keyboard.press("Delete");
173+
// Single-line flow YAML avoids CodeMirror auto-indentation corrupting block YAML.
174+
await editor.pressSequentially(
175+
"[{request: {method: GET, path: /tnr-yaml}, response: {status: 200, body: hi}}]",
176+
);
177+
await page.getByRole("button", { name: "Save" }).click();
178+
await expect(page.getByRole("button", { name: "Save" })).toHaveCount(0);
179+
await expect(page.getByText("/tnr-yaml").first()).toBeVisible();
180+
});
181+
182+
test("lock and unlock a mock", async ({ page }) => {
183+
await page.goto(MOCKS);
184+
await page.getByText("Session #1", { exact: true }).click();
185+
const lockToggle = () =>
186+
page
187+
.locator("button:has(.anticon-lock), button:has(.anticon-unlock)")
188+
.first();
189+
await expect(lockToggle()).toBeVisible();
190+
const isLockEndpoint = (r: { url(): string; request(): { method(): string } }) =>
191+
/\/mocks\/(un)?lock$/.test(new URL(r.url()).pathname) &&
192+
r.request().method() === "POST";
193+
194+
const first = page.waitForResponse(isLockEndpoint);
195+
await lockToggle().click();
196+
expect((await first).status()).toBe(200);
197+
198+
const second = page.waitForResponse(isLockEndpoint);
199+
await lockToggle().click();
200+
expect((await second).status()).toBe(200);
201+
});
202+
203+
test("autorefresh can be toggled on the history page", async ({ page }) => {
204+
await page.goto(HISTORY);
205+
await expect(page).toHaveURL(/session=/);
206+
await page.getByRole("button", { name: "Autorefresh" }).click();
207+
// Enabling autorefresh switches the control to its "pause" state.
208+
await expect(page.locator(".anticon-pause-circle").first()).toBeVisible();
209+
});
210+
211+
// --- Session lifecycle (last; Reset then rename are destructive / name-changing) -------------
212+
213+
test("'New Session' creates and selects a new session", async ({ page }) => {
214+
await page.goto(HISTORY);
215+
const before = await page.getByText(/Session #\d+/).count();
216+
await page.getByRole("button", { name: "New Session" }).click();
217+
await expect(page.getByText(/Session #\d+/)).toHaveCount(before + 1);
218+
});
219+
220+
test("selecting 'Session #1' in the sidebar shows its seeded mock", async ({ page }) => {
221+
await page.goto(MOCKS);
222+
await page.getByText("Session #1", { exact: true }).click();
223+
await expect(page).toHaveURL(/session=/);
224+
await expect(page.getByText(new RegExp(MOCK_ID)).first()).toBeVisible();
225+
});
226+
227+
test("'Reset Sessions' clears mocks and history", async ({ page }) => {
228+
await page.goto(MOCKS);
229+
await page.getByText("Session #1", { exact: true }).click();
230+
await expect(page.getByText(new RegExp(MOCK_ID)).first()).toBeVisible();
231+
await page.getByRole("button", { name: "Reset Sessions" }).click();
232+
await expect(page.getByText("No mocks found.")).toBeVisible();
233+
await expect(page.getByText(new RegExp(MOCK_ID))).toHaveCount(0);
234+
});
235+
236+
// Runs last: renaming changes the "Session #1" label the other tests rely on. After Reset there
237+
// is a fresh "Session #1" to rename.
238+
test("rename a session", async ({ page }) => {
239+
await page.goto(HISTORY);
240+
await page.getByText("Session #1", { exact: true }).click();
241+
await page.locator(".anticon-edit").first().click();
242+
// The edit control reveals an input pre-filled with the current name and a Save button.
243+
const boxes = page.getByRole("textbox");
244+
let input = boxes.first();
245+
for (let i = 0; i < (await boxes.count()); i++) {
246+
if ((await boxes.nth(i).inputValue()) === "Session #1") {
247+
input = boxes.nth(i);
248+
break;
249+
}
250+
}
251+
await input.fill("Renamed by TNR");
252+
await page.getByRole("button", { name: "Save" }).first().click();
253+
await expect(page.getByText("Renamed by TNR")).toBeVisible();
254+
});
255+
256+
// Last: importing selects the imported session, which would disturb session-selection in later
257+
// tests, so this runs at the very end.
258+
test("import a session from a file", async ({ page }) => {
259+
await page.goto(HISTORY);
260+
const payload = JSON.stringify([
261+
{
262+
id: "tnr-imported-id",
263+
name: "TNR Imported",
264+
date: "2020-02-12T00:04:29.5940297+01:00",
265+
history: [],
266+
mocks: [
267+
{
268+
request: { path: "/imported-tnr", method: "GET" },
269+
response: {
270+
status: 200,
271+
body: "hi",
272+
headers: { "Content-Type": ["application/json"] },
273+
},
274+
state: {
275+
id: "m-tnr",
276+
times_count: 0,
277+
locked: false,
278+
creation_date: "2020-02-12T00:04:29.5940297+01:00",
279+
},
280+
},
281+
],
282+
},
283+
]);
284+
// The sidebar "Load a session from a file" control is a hidden <input type=file> that reads
285+
// the file and POSTs it to /sessions/import.
286+
await page.locator('input[type="file"]').setInputFiles({
287+
name: "sessions.json",
288+
mimeType: "application/json",
289+
buffer: Buffer.from(payload),
290+
});
291+
await expect(page.getByText("TNR Imported")).toBeVisible();
292+
});

0 commit comments

Comments
 (0)