Skip to content

Commit 0cc1614

Browse files
authored
Add Playwright e2e coverage for the webview editors (#102)
1 parent fb2f31c commit 0cc1614

36 files changed

Lines changed: 3619 additions & 2537 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ out/
22
dist/
33
node_modules/
44
logs/
5+
test-results/
6+
playwright-report/

AGENTS.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,30 @@ Do not duplicate decoders across features.
9494
- **Objmod asset-browser model thumbnails:** visible model cards should enter a pending/spinner state immediately and stay there until the thumbnail is either loaded or decisively marked missing (`?`). Generation must drain visible thumbnails in DOM order, one complete thumbnail lifecycle at a time: host resolve -> warm webview renderer -> cache/write or missing decision -> next item. Do not pre-resolve/render later visible models in parallel, and do not add fixed inter-thumbnail idle delays after a thumbnail has finished. Cancel queued work only when a thumbnail scrolls out of view before it starts; when it returns, re-observe/requeue it. The grid thumbnail budget is intentionally strict: models above the host-side size cutoff (`WURST_MODEL_THUMB_MAX_MODEL_BYTES`, default 160 KB) should become `?` quickly rather than burning CPU; the full model preview can still be opened separately. Use `WURST_MODEL_THUMB_DISABLE_CACHE=1` for local validation so tests measure actual generation rather than cached webps.
9595
- **Local-only thumbnail validation:** use `npm run test:e2e:objmod-thumbs:local` with `WURST_OBJMOD_E2E=1` to launch VS Code against the checked-in `e2e/war3map.w3u` fixture, open the objmod asset browser, disable thumbnail cache, and assert visible FIFO order plus per-thumbnail timing (default max 200ms). Override `WURST_OBJMOD_E2E_PROJECT` and `WURST_OBJMOD_E2E_FILE` for a real map/project. This is intentionally not a CI test because it depends on local WC3 data and VS Code/Electron.
9696

97+
## Testing tiers
98+
99+
Three tiers, cheapest first. Put a test in the cheapest tier that can actually catch the regression.
100+
101+
1. **`npm test`** — fast Node harnesses in `scripts/` (fuzzy matching, image decoders, diagnostics, `test-webview.js`). `test-webview.js` transpiles real TS modules and runs them against a tiny DOM shim; it also holds structural guards that read sources as text. Use it for pure logic. It cannot judge layout, CSS, or the host↔webview protocol — don't add `assert.ok(source.includes(...))` guards for behaviour the Playwright tier can assert directly.
102+
103+
2. **`npm run test:e2e`** (Playwright, `e2e/specs/`) — the **real** webview bundles in real Chromium against the **real** host code, with only `vscode` itself faked. No VS Code launch and no Warcraft III install needed, so any developer can run it. Covers the objmod editor (browse/search/fields/tooltip editor/layout) and the editable `.w3i` and `.wpm` editors, including edit → undo/redo → save → bytes-on-disk round-trips.
104+
- **Not part of the `build.yml` CI job**, which substitutes `.ci/mocks/casc-ts` for the private sibling package; every parser in that mock throws, and these tests parse and re-serialize real binary fixtures. Running them in CI would need the mock to gain real `parseObjMod`/`serializeObjMod`/`parseW3i`/`serializeW3i`/`parseWpm`/`serializeWpm` implementations, or a job with access to the real siblings.
105+
- `e2e/harness/tsLoader.js` loads real TS sources with mocks. It reports `__dirname` as `<root>/dist` for anything under `src/`, matching what webpack produces — that is what makes `resources/wc3-knowledge-base.json` resolve, so field rows exist without a compiler or WC3 install.
106+
- `e2e/harness/objmodHost.js` / `mapEditorHosts.js` instantiate the **actual** `CustomEditorProvider` and mount it on a fake panel (`customEditorHost.js`), so `openCustomDocument``resolveCustomEditor` → message handler → edit stack → `saveCustomDocument` are the shipping paths.
107+
- The page is served over http and `webview.cspSource` points at that origin, so the shipped CSP has to genuinely admit what the page loads — a CSP regression fails the suite.
108+
- Assert on field **ids** and values from the fixture file, never on game-data labels: labels resolve through WorldEditStrings in CASC and differ between a machine with WC3 installed and CI.
109+
- Run `npm run compile-web` first (the `test:e2e` script does); the fixture fails loudly if `dist/webview/` is missing.
110+
111+
3. **`npm run test:e2e:local`** (Playwright, `e2e/local/`, opt-in) — a real VS Code window driven over CDP, plus the MDX render benchmark. Gated on `WURST_OBJMOD_E2E=1` / `WURST_MODEL_E2E=1`; each spec calls `skipUnlessEnabled()` at top level (a `beforeEach` in the shared fixtures module would only attach to whichever spec imported it first). Reserve this tier for what genuinely needs the real shell: thumbnail scheduling against real game data, the CodeLens-launched asset browser, and clipboard behaviour, which needs OS-trusted keystrokes and the VS Code window in the foreground.
112+
- `e2e/harness/vscodeLauncher.js` pins `workbench.editorAssociations` in the temp profile. Without it a `.w3u`/`.w3a` passed on the command line opens in the *text* editor on a cold `--extensionDevelopmentPath` start, because the extension host has not registered its custom editors yet — and no webview is ever created.
113+
97114
### Editable binary formats
98115
- **.w3i is an editable custom editor** (`wurst.w3iEditor`, in `mapDataPreview.ts`) backed by `casc-ts` `parseW3i`/`serializeW3i`, which use a **parse-prefix + opaque-tail** model: only leading string/scalar fields are editable; players/forces/lists are preserved verbatim in `file.tail` (and parsed best-effort for display only). Every save passes a round-trip safety gate (`serializeValidatedW3i`). TRIGSTR-backed strings edit `war3map.wts`; inline strings edit the w3i bytes. The other map-data formats remain read-only under `wurst.mapDataPreview` (the old read-only `renderW3i`/`parseW3i` in that file are retained but no longer routed to).
99116
- When adding a new editable binary format, mirror this: a casc-ts parser+serializer with a byte-exact round-trip test, a `CustomEditorProvider` with dirty tracking, and a serialize→re-parse→compare safety gate before any write.
100117

101118
## Validation checklist
102119
- Compile TypeScript (`npx tsc -p . --noEmit`) after command or API wiring changes.
120+
- Run `npm test` and, for anything touching a webview or an editable format, `npm run test:e2e`.
103121
- Run `npm run lint` (ESLint, with `eslint-plugin-sonarjs`'s recommended rules — see `eslint.config.js`) and fix anything it flags in files you touched before considering a change done. `src/webview/**` is intentionally excluded (bundled browser JS with a different style — see the ignores comment in `eslint.config.js`).
104122
- A handful of pre-existing findings are deliberately suppressed rather than fixed: `sonarjs/cognitive-complexity` and `sonarjs/no-nested-functions` are silenced per-site with `// eslint-disable-next-line ... -- TODO(lint-cleanup): ...` on functions that need a real decomposition pass, not a rushed one — don't add more of these without good reason, and prefer actually reducing complexity when touching one of these functions anyway. `sonarjs/code-eval`, `no-os-command-from-path`, `file-permissions`, `pseudo-random`, and `hashing` are disabled project-wide in `eslint.config.js` with reasoning for each (they assume an untrusted/internet-facing context this codebase doesn't have).
105123
- Ensure command appears in Command Palette via `contributes.commands`.

e2e/fixtures.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
'use strict';
2+
3+
/**
4+
* Playwright fixtures that put the real webview bundle in a real browser, talking to the real host.
5+
*
6+
* The bridge is deliberately thin — `acquireVsCodeApi()` in the page forwards straight to the host's
7+
* `onDidReceiveMessage`, and everything the host posts is replayed as a `window.postMessage`. Nothing
8+
* between the two is stubbed, so a broken message contract on either side fails these tests.
9+
*/
10+
11+
const fs = require('fs');
12+
const path = require('path');
13+
const { test: base, expect } = require('@playwright/test');
14+
15+
const { startHarnessServer } = require('./harness/server');
16+
const { createObjModHost } = require('./harness/objmodHost');
17+
const { createW3iHost, createWpmHost } = require('./harness/mapEditorHosts');
18+
const { root } = require('./harness/tsLoader');
19+
20+
/** Mirrors the webview API surface the shipped code uses. State lives in sessionStorage so it
21+
* survives a reload the same way VS Code's per-webview state does — that's what the persistence
22+
* tests reload against. */
23+
const VSCODE_API_SHIM = `
24+
window.__e2eOutbox = [];
25+
window.acquireVsCodeApi = function () {
26+
return {
27+
postMessage: function (message) {
28+
var plain;
29+
try { plain = JSON.parse(JSON.stringify(message)); } catch (e) { plain = { type: message && message.type }; }
30+
window.__e2eOutbox.push(plain);
31+
window.__e2eToHost(plain);
32+
},
33+
getState: function () {
34+
try { return JSON.parse(sessionStorage.getItem('__wv_state') || 'null'); } catch (e) { return null; }
35+
},
36+
setState: function (state) {
37+
try { sessionStorage.setItem('__wv_state', JSON.stringify(state)); } catch (e) { /* quota */ }
38+
return state;
39+
},
40+
};
41+
};
42+
`;
43+
44+
/**
45+
* Wires a page to a host and navigates to its HTML.
46+
* @returns {Promise<{ pageErrors: Error[], consoleErrors: string[], gotoHtml: (html: string) => Promise<void> }>}
47+
*/
48+
async function attachPageToHost(page, server, host) {
49+
const pageErrors = [];
50+
const consoleErrors = [];
51+
page.on('pageerror', (err) => pageErrors.push(err));
52+
page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
53+
54+
await page.exposeFunction('__e2eToHost', (message) => { host.receive(message); });
55+
await page.addInitScript(VSCODE_API_SHIM);
56+
57+
// Serialize host->page delivery: several posts can land in the same tick (details + icons), and
58+
// the webview's handlers are order-sensitive.
59+
let chain = Promise.resolve();
60+
host.onPost((message) => {
61+
chain = chain.then(async () => {
62+
try {
63+
await page.evaluate((m) => window.postMessage(m, '*'), JSON.parse(JSON.stringify(message)));
64+
} catch {
65+
// Page closed or navigating — the real webview drops these too.
66+
}
67+
});
68+
});
69+
70+
const gotoHtml = async (html) => {
71+
await page.goto(server.publish(html), { waitUntil: 'domcontentloaded' });
72+
};
73+
await gotoHtml(host.html);
74+
75+
return { pageErrors, consoleErrors, gotoHtml, flush: () => chain };
76+
}
77+
78+
const test = base.extend({
79+
// One server per worker: starting/stopping an http listener per test is pure overhead.
80+
// eslint-disable-next-line no-empty-pattern -- Playwright requires the fixture argument to be a destructuring pattern, even when nothing is used.
81+
server: [async ({}, use) => {
82+
const server = await startHarnessServer();
83+
await use(server);
84+
await server.close();
85+
}, { scope: 'worker' }],
86+
87+
/** Opens the object editor. `openObjMod({ config, fixtureDir, fileName })` -> { host, ... }. */
88+
openObjMod: async ({ page, server }, use) => {
89+
const opened = [];
90+
await use(async (options = {}) => {
91+
const bundle = path.join(root, 'dist', 'webview', 'objModEditorWebview.js');
92+
if (!fs.existsSync(bundle)) {
93+
throw new Error(`Missing ${path.relative(root, bundle)} — run "npm run compile-web" before the e2e suite.`);
94+
}
95+
const host = await createObjModHost({ origin: server.origin, ...options });
96+
opened.push(host);
97+
const wiring = await attachPageToHost(page, server, host);
98+
const handle = { host, page, ...wiring };
99+
// The tree/details panel paint from a reactive effect during bundle evaluation, so by the
100+
// time #tree has rows the editor is genuinely interactive.
101+
await page.waitForSelector('#object-editor', { state: 'attached' });
102+
return handle;
103+
});
104+
for (const host of opened) host.dispose();
105+
},
106+
107+
/** Opens the editable .w3i map-info editor. */
108+
openW3i: async ({ page, server }, use) => {
109+
const opened = [];
110+
await use(async (options = {}) => {
111+
const host = await createW3iHost({ origin: server.origin, ...options });
112+
opened.push(host);
113+
const wiring = await attachPageToHost(page, server, host);
114+
return { host, page, ...wiring };
115+
});
116+
for (const host of opened) host.dispose();
117+
},
118+
119+
/** Opens the editable .wpm pathing-map editor. */
120+
openWpm: async ({ page, server }, use) => {
121+
const opened = [];
122+
await use(async (options = {}) => {
123+
const host = await createWpmHost({ origin: server.origin, ...options });
124+
opened.push(host);
125+
const wiring = await attachPageToHost(page, server, host);
126+
return { host, page, ...wiring };
127+
});
128+
for (const host of opened) host.dispose();
129+
},
130+
});
131+
132+
module.exports = { test, expect, root };

e2e/harness/customEditorHost.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use strict';
2+
3+
/**
4+
* The VS Code side of a `CustomEditorProvider`, faked: a webview panel, the undo/redo edit stack
5+
* VS Code maintains from `onDidChangeCustomDocument`, and the save call.
6+
*
7+
* Shared by every editable-format harness (objmod, .w3i, .wpm) so each one only has to say how to
8+
* build its provider — the lifecycle around it stays identical to what VS Code actually does.
9+
*/
10+
11+
const path = require('path');
12+
13+
const { root } = require('./tsLoader');
14+
15+
/**
16+
* @param {object} opts
17+
* @param {string} opts.origin Harness server origin, used for cspSource and asWebviewUri.
18+
* @param {object} opts.provider
19+
* @param {object} opts.uri vscode.Uri of the document to open.
20+
* @param {object} [opts.openContext]
21+
*/
22+
async function mountCustomEditor(opts) {
23+
const { origin, provider, uri } = opts;
24+
25+
/** @type {Array<{label: string, undo: () => void, redo: () => void}>} */
26+
const editStack = [];
27+
let editIndex = 0;
28+
29+
const posted = [];
30+
const postListeners = new Set();
31+
const disposeListeners = [];
32+
let receiveMessage = () => {};
33+
let html = '';
34+
35+
const webview = {
36+
options: {},
37+
cspSource: origin,
38+
get html() { return html; },
39+
set html(value) { html = value; },
40+
asWebviewUri: (target) => {
41+
const abs = path.resolve(target.fsPath);
42+
const distWebview = path.join(root, 'dist', 'webview');
43+
const url = abs.startsWith(distWebview)
44+
? `${origin}/dist/webview/${path.relative(distWebview, abs).replace(/\\/g, '/')}`
45+
: `${origin}/file/${encodeURIComponent(abs)}`;
46+
return { toString: () => url };
47+
},
48+
postMessage: (message) => {
49+
posted.push(message);
50+
for (const listener of postListeners) listener(message);
51+
return Promise.resolve(true);
52+
},
53+
onDidReceiveMessage: (listener) => { receiveMessage = listener; return { dispose() {} }; },
54+
};
55+
56+
const panel = {
57+
webview,
58+
active: true,
59+
visible: true,
60+
viewColumn: 1,
61+
reveal() {},
62+
dispose() { for (const listener of disposeListeners) listener(); },
63+
onDidDispose: (listener) => { disposeListeners.push(listener); return { dispose() {} }; },
64+
onDidChangeViewState: () => ({ dispose() {} }),
65+
};
66+
67+
provider.onDidChangeCustomDocument((event) => {
68+
// VS Code truncates the redo branch when a new edit is made after an undo.
69+
editStack.length = editIndex;
70+
editStack.push({ label: event.label, undo: event.undo, redo: event.redo });
71+
editIndex = editStack.length;
72+
});
73+
74+
const doc = await provider.openCustomDocument(uri, opts.openContext || {});
75+
await provider.resolveCustomEditor(doc, panel);
76+
77+
return {
78+
provider,
79+
doc,
80+
panel,
81+
webview,
82+
posted,
83+
get html() { return html; },
84+
/** Deliver a message from the webview to the host, exactly as VS Code would. */
85+
receive: (message) => receiveMessage(message),
86+
onPost: (listener) => { postListeners.add(listener); return () => postListeners.delete(listener); },
87+
get editLabels() { return editStack.map((entry) => entry.label); },
88+
get undoDepth() { return editIndex; },
89+
undo: () => { if (editIndex > 0) editStack[--editIndex].undo(); },
90+
redo: () => { if (editIndex < editStack.length) editStack[editIndex++].redo(); },
91+
save: () => provider.saveCustomDocument(doc),
92+
/** Re-runs the provider's own reload, which rebuilds `html` from current document state. */
93+
rerender: async () => {
94+
if (doc.reload) await doc.reload();
95+
return html;
96+
},
97+
};
98+
}
99+
100+
module.exports = { mountCustomEditor };

0 commit comments

Comments
 (0)