|
| 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