Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ out/
dist/
node_modules/
logs/
test-results/
playwright-report/
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,30 @@ Do not duplicate decoders across features.
- **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.
- **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.

## Testing tiers

Three tiers, cheapest first. Put a test in the cheapest tier that can actually catch the regression.

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.

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.
- **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.
- `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.
- `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.
- 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.
- 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.
- Run `npm run compile-web` first (the `test:e2e` script does); the fixture fails loudly if `dist/webview/` is missing.

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

### Editable binary formats
- **.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).
- 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.

## Validation checklist
- Compile TypeScript (`npx tsc -p . --noEmit`) after command or API wiring changes.
- Run `npm test` and, for anything touching a webview or an editable format, `npm run test:e2e`.
- 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`).
- 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).
- Ensure command appears in Command Palette via `contributes.commands`.
Expand Down
132 changes: 132 additions & 0 deletions e2e/fixtures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'use strict';

/**
* Playwright fixtures that put the real webview bundle in a real browser, talking to the real host.
*
* The bridge is deliberately thin — `acquireVsCodeApi()` in the page forwards straight to the host's
* `onDidReceiveMessage`, and everything the host posts is replayed as a `window.postMessage`. Nothing
* between the two is stubbed, so a broken message contract on either side fails these tests.
*/

const fs = require('fs');
const path = require('path');
const { test: base, expect } = require('@playwright/test');

const { startHarnessServer } = require('./harness/server');
const { createObjModHost } = require('./harness/objmodHost');
const { createW3iHost, createWpmHost } = require('./harness/mapEditorHosts');
const { root } = require('./harness/tsLoader');

/** Mirrors the webview API surface the shipped code uses. State lives in sessionStorage so it
* survives a reload the same way VS Code's per-webview state does — that's what the persistence
* tests reload against. */
const VSCODE_API_SHIM = `
window.__e2eOutbox = [];
window.acquireVsCodeApi = function () {
return {
postMessage: function (message) {
var plain;
try { plain = JSON.parse(JSON.stringify(message)); } catch (e) { plain = { type: message && message.type }; }
window.__e2eOutbox.push(plain);
window.__e2eToHost(plain);
},
getState: function () {
try { return JSON.parse(sessionStorage.getItem('__wv_state') || 'null'); } catch (e) { return null; }
},
setState: function (state) {
try { sessionStorage.setItem('__wv_state', JSON.stringify(state)); } catch (e) { /* quota */ }
return state;
},
};
};
`;

/**
* Wires a page to a host and navigates to its HTML.
* @returns {Promise<{ pageErrors: Error[], consoleErrors: string[], gotoHtml: (html: string) => Promise<void> }>}
*/
async function attachPageToHost(page, server, host) {
const pageErrors = [];
const consoleErrors = [];
page.on('pageerror', (err) => pageErrors.push(err));
page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });

await page.exposeFunction('__e2eToHost', (message) => { host.receive(message); });
await page.addInitScript(VSCODE_API_SHIM);

// Serialize host->page delivery: several posts can land in the same tick (details + icons), and
// the webview's handlers are order-sensitive.
let chain = Promise.resolve();
host.onPost((message) => {
chain = chain.then(async () => {
try {
await page.evaluate((m) => window.postMessage(m, '*'), JSON.parse(JSON.stringify(message)));
} catch {
// Page closed or navigating — the real webview drops these too.
}
});
});

const gotoHtml = async (html) => {
await page.goto(server.publish(html), { waitUntil: 'domcontentloaded' });
};
await gotoHtml(host.html);

return { pageErrors, consoleErrors, gotoHtml, flush: () => chain };
}

const test = base.extend({
// One server per worker: starting/stopping an http listener per test is pure overhead.
// eslint-disable-next-line no-empty-pattern -- Playwright requires the fixture argument to be a destructuring pattern, even when nothing is used.
server: [async ({}, use) => {
const server = await startHarnessServer();
await use(server);
await server.close();
}, { scope: 'worker' }],

/** Opens the object editor. `openObjMod({ config, fixtureDir, fileName })` -> { host, ... }. */
openObjMod: async ({ page, server }, use) => {
const opened = [];
await use(async (options = {}) => {
const bundle = path.join(root, 'dist', 'webview', 'objModEditorWebview.js');
if (!fs.existsSync(bundle)) {
throw new Error(`Missing ${path.relative(root, bundle)} — run "npm run compile-web" before the e2e suite.`);
}
const host = await createObjModHost({ origin: server.origin, ...options });
opened.push(host);
const wiring = await attachPageToHost(page, server, host);
const handle = { host, page, ...wiring };
// The tree/details panel paint from a reactive effect during bundle evaluation, so by the
// time #tree has rows the editor is genuinely interactive.
await page.waitForSelector('#object-editor', { state: 'attached' });
return handle;
});
for (const host of opened) host.dispose();
},

/** Opens the editable .w3i map-info editor. */
openW3i: async ({ page, server }, use) => {
const opened = [];
await use(async (options = {}) => {
const host = await createW3iHost({ origin: server.origin, ...options });
opened.push(host);
const wiring = await attachPageToHost(page, server, host);
return { host, page, ...wiring };
});
for (const host of opened) host.dispose();
},

/** Opens the editable .wpm pathing-map editor. */
openWpm: async ({ page, server }, use) => {
const opened = [];
await use(async (options = {}) => {
const host = await createWpmHost({ origin: server.origin, ...options });
opened.push(host);
const wiring = await attachPageToHost(page, server, host);
return { host, page, ...wiring };
});
for (const host of opened) host.dispose();
},
});

module.exports = { test, expect, root };
100 changes: 100 additions & 0 deletions e2e/harness/customEditorHost.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
'use strict';

/**
* The VS Code side of a `CustomEditorProvider`, faked: a webview panel, the undo/redo edit stack
* VS Code maintains from `onDidChangeCustomDocument`, and the save call.
*
* Shared by every editable-format harness (objmod, .w3i, .wpm) so each one only has to say how to
* build its provider — the lifecycle around it stays identical to what VS Code actually does.
*/

const path = require('path');

const { root } = require('./tsLoader');

/**
* @param {object} opts
* @param {string} opts.origin Harness server origin, used for cspSource and asWebviewUri.
* @param {object} opts.provider
* @param {object} opts.uri vscode.Uri of the document to open.
* @param {object} [opts.openContext]
*/
async function mountCustomEditor(opts) {
const { origin, provider, uri } = opts;

/** @type {Array<{label: string, undo: () => void, redo: () => void}>} */
const editStack = [];
let editIndex = 0;

const posted = [];
const postListeners = new Set();
const disposeListeners = [];
let receiveMessage = () => {};
let html = '';

const webview = {
options: {},
cspSource: origin,
get html() { return html; },
set html(value) { html = value; },
asWebviewUri: (target) => {
const abs = path.resolve(target.fsPath);
const distWebview = path.join(root, 'dist', 'webview');
const url = abs.startsWith(distWebview)
? `${origin}/dist/webview/${path.relative(distWebview, abs).replace(/\\/g, '/')}`
: `${origin}/file/${encodeURIComponent(abs)}`;
return { toString: () => url };
},
postMessage: (message) => {
posted.push(message);
for (const listener of postListeners) listener(message);
return Promise.resolve(true);
},
onDidReceiveMessage: (listener) => { receiveMessage = listener; return { dispose() {} }; },
};

const panel = {
webview,
active: true,
visible: true,
viewColumn: 1,
reveal() {},
dispose() { for (const listener of disposeListeners) listener(); },
onDidDispose: (listener) => { disposeListeners.push(listener); return { dispose() {} }; },
onDidChangeViewState: () => ({ dispose() {} }),
};

provider.onDidChangeCustomDocument((event) => {
// VS Code truncates the redo branch when a new edit is made after an undo.
editStack.length = editIndex;
editStack.push({ label: event.label, undo: event.undo, redo: event.redo });
editIndex = editStack.length;
});

const doc = await provider.openCustomDocument(uri, opts.openContext || {});
await provider.resolveCustomEditor(doc, panel);

return {
provider,
doc,
panel,
webview,
posted,
get html() { return html; },
/** Deliver a message from the webview to the host, exactly as VS Code would. */
receive: (message) => receiveMessage(message),
onPost: (listener) => { postListeners.add(listener); return () => postListeners.delete(listener); },
get editLabels() { return editStack.map((entry) => entry.label); },
get undoDepth() { return editIndex; },
undo: () => { if (editIndex > 0) editStack[--editIndex].undo(); },
redo: () => { if (editIndex < editStack.length) editStack[editIndex++].redo(); },
save: () => provider.saveCustomDocument(doc),
/** Re-runs the provider's own reload, which rebuilds `html` from current document state. */
rerender: async () => {
if (doc.reload) await doc.reload();
return html;
},
};
}

module.exports = { mountCustomEditor };
Loading
Loading