From 6c801479eb859ad93f0ab11659d251599086af92 Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 17 Aug 2026 19:13:16 +0200 Subject: [PATCH 1/2] 1:Add Playwright e2e coverage for the webview editors 2: 3:Adds a browser-level test tier that runs the real webview bundles in real 4:Chromium against the real host code, with only `vscode` itself faked. 56 specs 5:cover the objmod editor (browse/search/field filters/tooltip editor/layout) and 6:the editable .w3i and .wpm editors, including full edit -> undo/redo -> save -> 7:bytes-on-disk round-trips. 8: 9:The harness mounts the actual CustomEditorProvider on a fake panel, so 10:openCustomDocument, resolveCustomEditor, buildHtml, the message handler, the 11:edit stack and saveCustomDocument are the shipping code paths rather than 12:reimplementations. The page is served over http with webview.cspSource pointed 13:at that origin, so the shipped CSP has to genuinely admit what the page loads. 14: 15:tsLoader reports __dirname as /dist for anything under src/, matching what 16:webpack emits; that is what makes resources/wc3-knowledge-base.json resolve, so 17:field rows are available without a compiler or Warcraft III install. 18: 19:Also ports the three local-only CDP scripts to Playwright. connectOverCDP plus a 20:frame search replaces the hand-rolled DevTools WebSocket client, target tracking 21:and execution-context bookkeeping; the assertions are kept. Two latent bugs in 22:that path surfaced while validating the port: a .w3a passed on the command line 23:opened in the text editor because the extension host had not registered its 24:custom editors yet (now fixed by pinning workbench.editorAssociations in the 25:temp profile), and bring-to-foreground.ps1 was invoked with the wrong parameter 26:name, so Ctrl+C silently no-opped and Ctrl+V pasted stale clipboard content. 27: 28:Fixes a real bug the new suite caught: `.dirty-badge { display: inline-block }` 29:outranks the user-agent [hidden] rule, so the .w3i editor showed "unsaved" on 30:clean documents. 31: 32:Includes the saved custom-colour palette for the tooltip editor (persisted in 33:globalState, seeded through the initial payload) and live "used colours" 34:swatches that refresh as the text changes, both covered by the new tooltip specs. 35: 36:The e2e tier is not wired into build.yml: that job substitutes .ci/mocks/casc-ts, 37:whose parsers all throw, and these tests parse and re-serialize real binary 38:fixtures. `npm test` is unchanged and still passes under the CI mocks. 39: 41: --- .gitignore | 2 + AGENTS.md | 18 + e2e/fixtures.js | 132 +++ e2e/harness/customEditorHost.js | 100 ++ e2e/harness/makeFixtures.js | 119 ++ e2e/harness/mapEditorHosts.js | 82 ++ e2e/harness/modelBench.js | 312 ++++++ e2e/harness/objmodHost.js | 109 ++ e2e/harness/server.js | 79 ++ e2e/harness/tsLoader.js | 93 ++ e2e/harness/vscodeLauncher.js | 294 +++++ e2e/harness/vscodeMock.js | 137 +++ e2e/local/fixtures.js | 127 +++ e2e/local/model-thumbnails.local.spec.js | 106 ++ e2e/local/objmod-clipboard.local.spec.js | 152 +++ e2e/local/objmod-thumbnails.local.spec.js | 251 +++++ e2e/specs/objmod-browse.spec.js | 134 +++ e2e/specs/objmod-fields.spec.js | 230 ++++ e2e/specs/objmod-layout.spec.js | 134 +++ e2e/specs/objmod-tooltip-editor.spec.js | 227 ++++ e2e/specs/w3i-editor.spec.js | 145 +++ e2e/specs/wpm-editor.spec.js | 199 ++++ eslint.config.js | 13 +- package-lock.json | 114 +- package.json | 11 +- playwright.config.js | 44 + scripts/model-thumbnail-e2e.js | 554 ---------- scripts/objmod-clipboard-e2e.js | 719 ------------ scripts/objmod-thumbnail-e2e.js | 1164 -------------------- scripts/test-webview.js | 117 +- src/features/mapDataPreview.ts | 3 + src/features/objModPreview.ts | 51 +- src/webview/objModEditor/detailsPanel.ts | 51 +- src/webview/objModEditor/fieldDisplay.ts | 80 +- src/webview/objModEditor/richTextEditor.ts | 16 +- src/webview/objModEditor/types.ts | 1 + 36 files changed, 3583 insertions(+), 2537 deletions(-) create mode 100644 e2e/fixtures.js create mode 100644 e2e/harness/customEditorHost.js create mode 100644 e2e/harness/makeFixtures.js create mode 100644 e2e/harness/mapEditorHosts.js create mode 100644 e2e/harness/modelBench.js create mode 100644 e2e/harness/objmodHost.js create mode 100644 e2e/harness/server.js create mode 100644 e2e/harness/tsLoader.js create mode 100644 e2e/harness/vscodeLauncher.js create mode 100644 e2e/harness/vscodeMock.js create mode 100644 e2e/local/fixtures.js create mode 100644 e2e/local/model-thumbnails.local.spec.js create mode 100644 e2e/local/objmod-clipboard.local.spec.js create mode 100644 e2e/local/objmod-thumbnails.local.spec.js create mode 100644 e2e/specs/objmod-browse.spec.js create mode 100644 e2e/specs/objmod-fields.spec.js create mode 100644 e2e/specs/objmod-layout.spec.js create mode 100644 e2e/specs/objmod-tooltip-editor.spec.js create mode 100644 e2e/specs/w3i-editor.spec.js create mode 100644 e2e/specs/wpm-editor.spec.js create mode 100644 playwright.config.js delete mode 100644 scripts/model-thumbnail-e2e.js delete mode 100644 scripts/objmod-clipboard-e2e.js delete mode 100644 scripts/objmod-thumbnail-e2e.js diff --git a/.gitignore b/.gitignore index 38ef4c0..d6d72c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ out/ dist/ node_modules/ logs/ +test-results/ +playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index ca38cc1..a8072a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `/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`. diff --git a/e2e/fixtures.js b/e2e/fixtures.js new file mode 100644 index 0000000..5d50429 --- /dev/null +++ b/e2e/fixtures.js @@ -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 }>} + */ +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 }; diff --git a/e2e/harness/customEditorHost.js b/e2e/harness/customEditorHost.js new file mode 100644 index 0000000..3ec4b12 --- /dev/null +++ b/e2e/harness/customEditorHost.js @@ -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 }; diff --git a/e2e/harness/makeFixtures.js b/e2e/harness/makeFixtures.js new file mode 100644 index 0000000..f04ada1 --- /dev/null +++ b/e2e/harness/makeFixtures.js @@ -0,0 +1,119 @@ +'use strict'; + +/** + * Generates the binary map-data fixtures the e2e specs open. + * + * They are built here rather than checked in so the bytes stay readable/reviewable as code, and so a + * casc-ts format change surfaces as a generator failure instead of a stale blob that quietly parses + * into something else. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { repoRequire } = require('./tsLoader'); + +const { serializeW3i, serializeWpm, BinWriter } = repoRequire('casc-ts/formats'); + +const W3I_VERSION = 31; // Reforged-era; exercises the v28 game-version block and the v31 tail skips. + +/** A tail the display-only player/force parser can read, so the rendered editor has real rows. */ +function buildW3iTail() { + const w = new BinWriter(512); + w.writeI32(0); // fog type + w.writeF32(3000); w.writeF32(5000); w.writeF32(0.5); // fog start/end/density + w.writeI32(0); // fog color + w.writeI32(0); // global weather id + w.writeString('Default'); // sound environment + w.writeU8('L'.charCodeAt(0)); // light environment tileset + w.writeI32(0); // water color + w.writeI32(0); // script language (v28+) + w.writeI32(0); w.writeI32(0); // supported graphics modes + game data version (v31+) + + w.writeI32(2); // players + const player = (num, type, race, name) => { + w.writeI32(num); w.writeI32(type); w.writeI32(race); w.writeI32(0); + w.writeString(name); + w.writeF32(0); w.writeF32(0); // start x/y + w.writeI32(0); w.writeI32(0); // ally low/high priority + w.writeI32(0); w.writeI32(0); // enemy low/high priority (v31+) + }; + player(0, 1, 1, 'Player 1 (Human)'); + player(1, 2, 2, 'Player 2 (Computer)'); + + w.writeI32(1); // forces + w.writeI32(0); w.writeU32(0xffffffff); w.writeString('Force 1'); + return w.toBuffer(); +} + +function buildW3i() { + return serializeW3i({ + version: W3I_VERSION, + saves: 1, + editorVersion: 6072, + gameVersionRaw: (() => { + const b = Buffer.alloc(16); + b.writeUInt32LE(1, 0); b.writeUInt32LE(36, 4); b.writeUInt32LE(1, 8); b.writeUInt32LE(20363, 12); + return b; + })(), + // Mixed on purpose: `name` is a wts-backed TRIGSTR (editing it must route to war3map.wts), + // `author` is an inline string (editing it must rewrite the w3i bytes). + name: 'TRIGSTR_001', + author: 'Wurst E2E', + description: 'TRIGSTR_002', + recommendedPlayers: '2', + cameraBounds: Buffer.alloc(32), + margins: Buffer.alloc(16), + width: 64, + height: 64, + flags: 0x0001 | 0x0400, + tileset: 'L', + loadingBackground: -1, + loadingModel: 'war3mapImported\\LoadingScreen.mdx', + loadingText: 'Loading text', + loadingTitle: 'Loading title', + loadingSubtitle: 'Loading subtitle', + gameDataSet: 0, + prologuePath: '', + prologueText: 'Prologue text', + prologueTitle: 'Prologue title', + prologueSubtitle: 'Prologue subtitle', + tail: buildW3iTail(), + }); +} + +const WTS = `STRING 1 +{ +E2E Map Name +} + +STRING 2 +{ +E2E map description spanning +two lines. +} +`; + +/** 16x16 pathing map with a recognisable block of blocked cells to assert paint/erase against. */ +function buildWpm() { + const width = 16; + const height = 16; + const data = Buffer.alloc(width * height, 0); + for (let y = 4; y < 8; y++) { + for (let x = 4; x < 8; x++) data[y * width + x] = 0x02; // walkability blocked + } + return serializeWpm({ version: 0, width, height, data, tail: Buffer.alloc(0) }); +} + +/** Writes a fresh temp dir containing war3map.w3i / .wts / .wpm and returns its path. */ +function makeMapFixtureDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-e2e-map-')); + fs.writeFileSync(path.join(dir, 'war3map.w3i'), buildW3i()); + fs.writeFileSync(path.join(dir, 'war3map.wts'), WTS, 'utf8'); + fs.writeFileSync(path.join(dir, 'war3map.wpm'), buildWpm()); + fs.writeFileSync(path.join(dir, 'wurst.build'), 'projectName = wurst-e2e\n'); + return dir; +} + +module.exports = { makeMapFixtureDir, buildW3i, buildWpm, WTS, W3I_VERSION }; diff --git a/e2e/harness/mapEditorHosts.js b/e2e/harness/mapEditorHosts.js new file mode 100644 index 0000000..ee11de8 --- /dev/null +++ b/e2e/harness/mapEditorHosts.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Harnesses for the two editable map-data formats whose webview JS ships as an inline ``; + +/** + * Runs in the browser: loads each model into one warm War3Viewer, feeds it the textures it asks + * for, renders a deterministic still frame, and reports pixel metrics plus timings. + * The first fixture is run twice; the first pass is flagged as warm-up and excluded from assertions. + */ +async function runModelBench({ fixtures, timeoutMs }) { + const b64ToArrayBuffer = (b64) => { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out.buffer; + }; + const normalize = (value) => String(value || '').replace(/\0/g, '').replace(/\//g, '\\').toLowerCase(); + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + + const metricsForImage = (imageData) => { + const px = imageData.data; + let alphaPixels = 0; + let visiblePixels = 0; + let lumaSum = 0; + let maxLuma = 0; + let h1 = 0x811c9dc5; + let h2 = 0x27d4eb2d; + for (let i = 0; i < px.length; i += 4) { + const a = px[i + 3]; + const rgbVisible = px[i] + px[i + 1] + px[i + 2] > 24; + if (a > 12) alphaPixels++; + if (a > 12 || rgbVisible) { + const luma = px[i] * 0.2126 + px[i + 1] * 0.7152 + px[i + 2] * 0.0722; + visiblePixels++; + lumaSum += luma; + if (luma > maxLuma) maxLuma = luma; + } + h1 = Math.imul(h1 ^ px[i], 0x01000193) >>> 0; + h1 = Math.imul(h1 ^ px[i + 1], 0x01000193) >>> 0; + h1 = Math.imul(h1 ^ px[i + 2], 0x01000193) >>> 0; + h2 = Math.imul(h2 ^ a, 0x85ebca6b) >>> 0; + } + return { + alphaPixels, + visiblePixels, + avgLuma: visiblePixels ? lumaSum / visiblePixels : 0, + maxLuma, + hash: `${imageData.width}x${imageData.height}-${h1.toString(16).padStart(8, '0')}-${h2.toString(16).padStart(8, '0')}`, + }; + }; + + const canvas = document.getElementById('canvas'); + const gizmo = document.getElementById('gizmo'); + const viewport = document.getElementById('viewport'); + canvas.width = 96; + canvas.height = 96; + const messages = []; + let loadedResolve = null; + let errors = []; + window.War3Viewer.init({ + canvas3d: canvas, + gizmo, + viewport, + vscodeApi: { postMessage(message) { messages.push(message); } }, + callbacks: { + onModelLoaded(info) { if (loadedResolve) loadedResolve(info); }, + onFrameUpdate() {}, + onDebug() {}, + onError(message) { errors.push(message); }, + }, + }); + + async function withTimeout(label, promise, ms) { + let timer = 0; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = window.setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }), + ]); + } finally { + if (timer) window.clearTimeout(timer); + } + } + + async function runOne(fixture, warmup) { + messages.length = 0; + errors = []; + const t0 = performance.now(); + let loadedReject = null; + let waitingForLoad = true; + const loaded = new Promise((resolve, reject) => { loadedResolve = resolve; loadedReject = reject; }); + try { + window.War3Viewer.loadModel(b64ToArrayBuffer(fixture.modelBase64), fixture.fileName, fixture.format, { autoplay: false }); + } catch (err) { + loadedReject(err); + } + let info; + try { + info = await withTimeout(`${fixture.name} loadModel`, new Promise((resolve, reject) => { + loaded.then(resolve, reject); + const check = () => { + if (!waitingForLoad) return; + if (errors.length) reject(new Error(`${fixture.name}: ${errors.join('; ')}`)); + else requestAnimationFrame(check); + }; + requestAnimationFrame(check); + }), timeoutMs); + } finally { + waitingForLoad = false; + loadedResolve = null; + } + const tLoaded = performance.now(); + if (errors.length) throw new Error(`${fixture.name}: ${errors.join('; ')}`); + + const requests = messages.filter((message) => message && message.type === 'requestTextures'); + const loadedTextures = []; + const missingTextures = []; + for (const request of requests) { + for (const texPath of request.paths || []) { + const key = normalize(texPath); + const base = key.split('\\').pop(); + const payload = fixture.textures[key] || fixture.textures[base]; + if (!payload) { + missingTextures.push(texPath); + window.War3Viewer.onTexture(texPath, null); + } else if (payload.kind === 'dds') { + loadedTextures.push(texPath); + window.War3Viewer.onTextureDds(texPath, b64ToArrayBuffer(payload.base64)); + } else { + loadedTextures.push(texPath); + window.War3Viewer.onTexture(texPath, b64ToArrayBuffer(payload.base64)); + } + } + } + const tTextures = performance.now(); + + // A fixed frame ~20% into the Stand animation keeps the snapshot hash deterministic. + const standIndex = Math.max(0, info.sequences.findIndex((seq) => /stand/i.test(seq.name || ''))); + if (info.sequences.length) { + const seq = info.sequences[standIndex] || info.sequences[0]; + window.War3Viewer.setSequence(standIndex); + window.War3Viewer.setFrame(Math.round(seq.start + Math.max(0, seq.end - seq.start) * 0.2)); + } + window.War3Viewer.resetCamera(); + window.War3Viewer.zoomOut(); + window.War3Viewer.zoomOut(); + window.War3Viewer.setAutoplay(false); + await nextFrame(); + await nextFrame(); + window.War3Viewer.renderStillFrame(); + const metrics = metricsForImage(window.War3Viewer.readPixelsImageData()); + const tDone = performance.now(); + return { + name: fixture.name, + warmup, + geosets: info.geosetCount, + textures: info.textureCount, + requestedTextures: requests.reduce((sum, request) => sum + ((request.paths || []).length), 0), + loadedTextures: loadedTextures.length, + missingTextures, + loadMs: tLoaded - t0, + textureMs: tTextures - tLoaded, + renderMs: tDone - tTextures, + totalMs: tDone - t0, + alphaPixels: metrics.alphaPixels, + visiblePixels: metrics.visiblePixels, + avgLuma: metrics.avgLuma, + maxLuma: metrics.maxLuma, + snapshotHash: metrics.hash, + webpBytes: Math.round((canvas.toDataURL('image/webp', 0.58).length - 'data:image/webp;base64,'.length) * 0.75), + }; + } + + const results = []; + if (fixtures.length) results.push(await runOne(fixtures[0], true)); + for (const fixture of fixtures) results.push(await runOne(fixture, false)); + return results; +} + +function readSnapshots() { + try { return JSON.parse(fs.readFileSync(SNAPSHOT_FILE, 'utf8')); } catch { return {}; } +} + +function writeSnapshots(results) { + const snapshots = {}; + for (const result of results.filter((entry) => !entry.warmup)) { + snapshots[result.name] = { + snapshotHash: result.snapshotHash, + alphaPixels: result.alphaPixels, + visiblePixels: result.visiblePixels, + avgLuma: Number(result.avgLuma.toFixed(2)), + maxLuma: Number(result.maxLuma.toFixed(2)), + }; + } + fs.mkdirSync(path.dirname(SNAPSHOT_FILE), { recursive: true }); + fs.writeFileSync(SNAPSHOT_FILE, `${JSON.stringify(snapshots, null, 2)}\n`); +} + +module.exports = { + BENCH_PAGE_HTML, + SNAPSHOT_FILE, + buildFixtures, + resolveModelPaths, + runModelBench, + readSnapshots, + writeSnapshots, +}; diff --git a/e2e/harness/objmodHost.js b/e2e/harness/objmodHost.js new file mode 100644 index 0000000..4c010c8 --- /dev/null +++ b/e2e/harness/objmodHost.js @@ -0,0 +1,109 @@ +'use strict'; + +/** + * Boots the *real* object-editor host (`ObjModEditorProvider` from src/features/objModPreview.ts) + * outside VS Code, against the fake panel in customEditorHost.js. + * + * This is not a reimplementation: `openCustomDocument`, `resolveCustomEditor`, `buildHtml`, the + * message handler, the undo/redo edits and `saveCustomDocument` are the shipping code paths. Only + * VS Code itself is faked, so a Playwright page driving the real webview bundle against this + * exercises the same host<->webview contract the extension ships. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { createTsLoader, root } = require('./tsLoader'); +const { createVscodeMock, fileUri } = require('./vscodeMock'); +const { mountCustomEditor } = require('./customEditorHost'); + +// Reaching into module-private values rather than adding test-only exports to the production file. +// A rename there fails loudly here (ReferenceError at load), which is the intended signal. +const OBJMOD_INTERNALS = ` +export const __e2e = { ObjModEditorProvider, ObjModDocument, loadEditableObjMod, buildHtml, applyFieldEdit, modDisplayValue, serializeValidated }; +`; + +function createMemento(seed = {}) { + const store = new Map(Object.entries(seed)); + return { + get: (key, fallback) => (store.has(key) ? store.get(key) : fallback), + update: (key, value) => { store.set(key, value); return Promise.resolve(); }, + keys: () => Array.from(store.keys()), + _store: store, + }; +} + +/** Copies a fixture directory into a temp dir so save/edit tests never mutate the repo copy. */ +function copyFixtureDir(fixtureDir, prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + for (const name of fs.readdirSync(fixtureDir)) { + const from = path.join(fixtureDir, name); + if (fs.statSync(from).isFile()) fs.copyFileSync(from, path.join(dir, name)); + } + return dir; +} + +/** + * @param {object} opts + * @param {string} opts.origin Harness server origin. Becomes the webview's cspSource, so the + * page's real CSP has to genuinely admit the bundle it loads — a + * CSP regression fails the test instead of passing quietly. + * @param {string} [opts.fixtureDir] Directory holding war3map.w3u & friends. Defaults to e2e/. + * @param {string} [opts.fileName] File within it to open. Defaults to war3map.w3u. + * @param {Record} [opts.config] wurst.* settings the host should read. + */ +async function createObjModHost(opts) { + const fixtureDir = copyFixtureDir(opts.fixtureDir || path.join(root, 'e2e'), 'wurst-e2e-objmod-'); + const fileName = opts.fileName || 'war3map.w3u'; + const target = path.join(fixtureDir, fileName); + + let mounted; + const vscodeMock = createVscodeMock({ + config: opts.config || {}, + workspaceFolders: [{ uri: fileUri(fixtureDir), name: 'fixture', index: 0 }], + // The webview forwards Ctrl+Z / Ctrl+S to the host as VS Code commands; wiring them to the + // real edit stack means a keystroke in the page actually undoes or saves. + onCommand: (command) => { + if (!mounted) return undefined; + if (command === 'undo') mounted.undo(); + else if (command === 'redo') mounted.redo(); + else if (command === 'workbench.action.files.save') return mounted.save(); + return undefined; + }, + }); + + const load = createTsLoader({ + mocks: { vscode: vscodeMock }, + augment: { 'src/features/objModPreview.ts': OBJMOD_INTERNALS }, + }); + const mod = load('src/features/objModPreview.ts'); + + const workspaceState = createMemento(opts.workspaceState); + const globalState = createMemento(opts.globalState); + const provider = new mod.__e2e.ObjModEditorProvider(fileUri(root), workspaceState, globalState); + + mounted = await mountCustomEditor({ origin: opts.origin, provider, uri: fileUri(target) }); + + Object.assign(mounted, { + fixtureDir, + filePath: target, + vscodeMock, + workspaceState, + globalState, + internals: mod.__e2e, + readFile: (name) => fs.readFileSync(path.join(fixtureDir, name || fileName)), + dispose: () => { + mounted.panel.dispose(); + mounted.doc.dispose(); + fs.rmSync(fixtureDir, { recursive: true, force: true }); + }, + }); + // Object.assign would evaluate this getter once and freeze the result, so define it directly. + Object.defineProperty(mounted, 'isDirty', { + get() { return mounted.doc.currentRevision !== mounted.doc.savedRevision; }, + }); + return mounted; +} + +module.exports = { createObjModHost, createMemento, copyFixtureDir }; diff --git a/e2e/harness/server.js b/e2e/harness/server.js new file mode 100644 index 0000000..6249349 --- /dev/null +++ b/e2e/harness/server.js @@ -0,0 +1,79 @@ +'use strict'; + +/** + * Minimal static server standing in for VS Code's webview resource scheme. + * + * The page is served over a real http origin rather than `page.setContent`, because the host's own + * Content-Security-Policy is built from `webview.cspSource` — pointing that at this origin means the + * shipped CSP has to genuinely admit the bundle and assets the page loads, so a CSP regression shows + * up as a broken test instead of passing silently. + */ + +const fs = require('fs'); +const http = require('http'); +const path = require('path'); + +const { root } = require('./tsLoader'); + +const MIME = { + '.js': 'text/javascript; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.ttf': 'font/ttf', + '.map': 'application/json; charset=utf-8', +}; + +async function startHarnessServer() { + /** @type {Map} */ + const pages = new Map(); + let nextId = 0; + + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + const send = (status, type, body) => { + res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); + res.end(body); + }; + + if (url.pathname.startsWith('/page/')) { + const html = pages.get(url.pathname.slice('/page/'.length)); + if (html == null) return send(404, 'text/plain', 'no such page'); + return send(200, MIME['.html'], html); + } + + let abs; + if (url.pathname.startsWith('/file/')) { + abs = decodeURIComponent(url.pathname.slice('/file/'.length)); + } else if (url.pathname.startsWith('/dist/')) { + abs = path.join(root, url.pathname.replace(/^\//, '')); + // Keep served files inside the repo's dist dir — this server is only ever local, but a + // traversal here would silently read arbitrary files into the page under test. + if (!path.resolve(abs).startsWith(path.join(root, 'dist'))) return send(403, 'text/plain', 'denied'); + } else { + return send(404, 'text/plain', 'not found'); + } + + if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return send(404, 'text/plain', `missing: ${abs}`); + return send(200, MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream', fs.readFileSync(abs)); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + + return { + origin, + /** Registers a page body and returns the URL to navigate to. */ + publish(html) { + const id = String(nextId++); + pages.set(id, html); + return `${origin}/page/${id}`; + }, + async close() { + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +module.exports = { startHarnessServer }; diff --git a/e2e/harness/tsLoader.js b/e2e/harness/tsLoader.js new file mode 100644 index 0000000..9e82da6 --- /dev/null +++ b/e2e/harness/tsLoader.js @@ -0,0 +1,93 @@ +'use strict'; + +/** + * Loads the extension's real TypeScript sources into Node, transpiled in-memory, with arbitrary + * modules (`vscode`, CASC storage, ...) swapped for mocks. + * + * Shared by the fast unit harness (scripts/test-webview.js) and the Playwright e2e harness, which + * needs it to call the *real* host-side HTML builders and message handlers without VS Code. + */ + +const fs = require('fs'); +const path = require('path'); +const Module = require('module'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '..', '..'); +// Node resolves bare specifiers relative to the *requiring* file, and this harness may be invoked +// from outside the repo (Playwright's runner, a scratch script), so resolve them against the repo. +const repoRequire = Module.createRequire(path.join(root, 'package.json')); + +function resolveRelative(fromFile, request) { + const resolved = path.resolve(path.dirname(fromFile), request); + if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) return resolved; + if (fs.existsSync(`${resolved}.ts`)) return `${resolved}.ts`; + if (fs.existsSync(path.join(resolved, 'index.ts'))) return path.join(resolved, 'index.ts'); + return resolved; +} + +function toRepoRelative(abs) { + return path.relative(root, abs).replace(/\\/g, '/'); +} + +/** + * @param {object} [options] + * @param {Record} [options.mocks] + * Keyed by either a bare module specifier ('vscode') or a repo-relative path + * ('src/features/preview/cascStorage.ts'). + * @param {Record} [options.augment] + * Extra source appended to a module before transpiling, keyed by repo-relative path. Used to + * re-export module-private functions for tests, so production sources keep no test-only exports. + * A rename on the production side surfaces as a loud ReferenceError here, which is the point. + */ +function createTsLoader(options = {}) { + const mocks = options.mocks || {}; + const augment = options.augment || {}; + const cache = new Map(); + + const load = (relPath) => { + const abs = path.resolve(root, relPath); + if (cache.has(abs)) return cache.get(abs).exports; + + const rel = toRepoRelative(abs); + let src = fs.readFileSync(abs, 'utf8'); + if (Object.prototype.hasOwnProperty.call(augment, rel)) src += `\n${augment[rel]}\n`; + + const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + fileName: abs, + }).outputText; + + const mod = { exports: {} }; + cache.set(abs, mod); + const localRequire = (request) => { + if (Object.prototype.hasOwnProperty.call(mocks, request)) return mocks[request]; + if (request.startsWith('.')) { + const target = resolveRelative(abs, request); + const targetRel = toRepoRelative(target); + if (Object.prototype.hasOwnProperty.call(mocks, targetRel)) return mocks[targetRel]; + return load(targetRel); + } + return repoRequire(request); + }; + // Webpack collapses all of src/ into dist/extension.js, so shipped code sees `__dirname` as + // /dist regardless of which source file it was written in — which is how + // compilerKnowledgeBase.ts resolves ../resources/wc3-knowledge-base.json. Reproduce that + // here; using the real source directory would silently break that lookup. + const bundleDir = rel.startsWith('src/') ? path.join(root, 'dist') : path.dirname(abs); + new Function('exports', 'module', 'require', '__filename', '__dirname', js)( + mod.exports, mod, localRequire, path.join(bundleDir, 'extension.js'), bundleDir, + ); + return mod.exports; + }; + + // Exposed so a caller can reset between tests, or pre-seed a `{ exports }` entry to stub out a + // heavy sibling module without threading it through `mocks`. + load.cache = cache; + return load; +} + +/** Convenience for the common "no mocks, one shared cache" case. */ +const sharedLoader = createTsLoader(); + +module.exports = { createTsLoader, sharedLoader, repoRequire, root }; diff --git a/e2e/harness/vscodeLauncher.js b/e2e/harness/vscodeLauncher.js new file mode 100644 index 0000000..3781762 --- /dev/null +++ b/e2e/harness/vscodeLauncher.js @@ -0,0 +1,294 @@ +'use strict'; + +/** + * Launches a real VS Code with this extension loaded and attaches Playwright to it over CDP. + * + * This replaces the hand-rolled DevTools WebSocket client, target tracking and execution-context + * bookkeeping the local e2e scripts used to carry: Playwright's `connectOverCDP` already attaches to + * every target, and webviews surface as ordinary frames, so "find the objmod webview" is a frame + * search instead of a Target/Runtime protocol dance. + * + * Local-only by nature — it needs an installed VS Code (and, for the thumbnail suites, a Warcraft III + * install), so nothing here runs in CI. + */ + +const childProcess = require('child_process'); +const fs = require('fs'); +const http = require('http'); +const net = require('net'); +const os = require('os'); +const path = require('path'); + +const { chromium } = require('@playwright/test'); + +const { root } = require('./tsLoader'); + +function codeCandidates() { + const explicit = process.env.WURST_OBJMOD_E2E_CODE; + const candidates = explicit ? [explicit] : []; + if (process.platform === 'win32') { + const local = process.env.LOCALAPPDATA || ''; + const programFiles = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)']].filter(Boolean); + candidates.push( + path.join(local, 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'), + path.join(local, 'Programs', 'Microsoft VS Code', 'Code.exe'), + ...programFiles.map((dir) => path.join(dir, 'Microsoft VS Code', 'bin', 'code.cmd')), + ...programFiles.map((dir) => path.join(dir, 'Microsoft VS Code', 'Code.exe')), + ); + } else if (process.platform === 'darwin') { + candidates.push('/Applications/Visual Studio Code.app/Contents/MacOS/Electron'); + } else { + candidates.push('code', 'code-insiders'); + } + if (!explicit && process.env.VSCODE_PATH) candidates.push(process.env.VSCODE_PATH); + return candidates; +} + +function findCode() { + // The `code.cmd` shim re-execs and detaches, which loses the remote-debugging port, so prefer the + // executable sitting next to it. + if (process.platform === 'win32' && !process.env.WURST_OBJMOD_E2E_CODE) { + const localShim = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'); + const localExe = path.resolve(path.dirname(localShim), '..', 'Code.exe'); + if (fs.existsSync(localExe)) return localExe; + } + for (const candidate of codeCandidates()) { + if (!candidate) continue; + const clean = String(candidate).replace(/^['"]|['"]$/g, ''); + const normalized = /(?:^|[\\/])code\.cmd$/i.test(clean) + ? path.resolve(path.dirname(clean), '..', 'Code.exe') + : clean; + if (normalized.includes(path.sep) && !fs.existsSync(normalized)) continue; + return normalized; + } + throw new Error('Could not find VS Code. Set WURST_OBJMOD_E2E_CODE.'); +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +function requestJson(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, (response) => { + let body = ''; + response.on('data', (chunk) => { body += chunk; }); + response.on('end', () => { + try { resolve(JSON.parse(body)); } catch (error) { reject(error); } + }); + }); + request.on('error', reject); + request.setTimeout(2000, () => request.destroy(new Error(`Timed out requesting ${url}`))); + }); +} + +async function waitForDevtools(port, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + for (const host of ['127.0.0.1', 'localhost']) { + try { + const version = await requestJson(`http://${host}:${port}/json/version`); + if (version && version.webSocketDebuggerUrl) return `http://${host}:${port}`; + } catch (error) { lastError = error; } + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for VS Code DevTools on port ${port}: ${lastError && lastError.message}`); +} + +/** + * VS Code's own window has to be the OS foreground window for clipboard keystrokes to work: + * Chromium refuses copy/cut when `document.hasFocus()` is false, so a background window silently + * turns Ctrl+C into a no-op and a later Ctrl+V pastes whatever was already on the clipboard. + * + * The script matches windows by looking for the needle in the process command line, which is why the + * unique temp user-data-dir is what gets passed. + */ +function bringWindowToForeground(userDataDir) { + if (process.platform !== 'win32') return ''; + const script = path.join(root, 'scripts', 'bring-to-foreground.ps1'); + if (!fs.existsSync(script)) return ''; + try { + return String(childProcess.execFileSync( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Needle', userDataDir], + { encoding: 'utf8', windowsHide: true, timeout: 10000 }, + )).trim(); + } catch { + return ''; + } +} + +function killTree(child) { + if (!child || child.exitCode !== null) return; + try { + if (process.platform === 'win32') { + childProcess.execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch { /* already gone */ } +} + +/** + * Every binary format this extension owns a custom editor for. + * + * Passing such a file on the command line opens it in whichever editor VS Code considers default at + * that moment — and on a cold `--extensionDevelopmentPath` start the extension host has usually not + * registered its custom editors yet, so the file lands in the *text* editor ("the file is not + * displayed... because it is either binary") and no webview is ever created. Pinning the association + * up front makes the custom editor win regardless of activation timing. + */ +const EDITOR_ASSOCIATIONS = { + '*.w3u': 'wurst.objModPreview', + '*.w3t': 'wurst.objModPreview', + '*.w3a': 'wurst.objModPreview', + '*.w3b': 'wurst.objModPreview', + '*.w3d': 'wurst.objModPreview', + '*.w3h': 'wurst.objModPreview', + '*.w3q': 'wurst.objModPreview', + '*.w3i': 'wurst.w3iEditor', + '*.wpm': 'wurst.wpmPreview', +}; + +function writeUserSettings(userDataDir, settings) { + const dir = path.join(userDataDir, 'User'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ + 'workbench.editorAssociations': EDITOR_ASSOCIATIONS, + 'workbench.startupEditor': 'none', + 'window.restoreWindows': 'none', + 'update.mode': 'none', + 'telemetry.telemetryLevel': 'off', + 'extensions.autoUpdate': false, + ...settings, + }, null, 2)); +} + +/** + * @param {object} opts + * @param {string} opts.projectPath Folder to open as the workspace. + * @param {string[]} [opts.files] Files to open in editors. + * @param {Record} [opts.settings] Extra user settings. + * @param {Record} [opts.env] + * @param {number} [opts.timeoutMs] + */ +async function launchVsCode(opts) { + const timeoutMs = opts.timeoutMs || Number(process.env.WURST_OBJMOD_E2E_TIMEOUT_MS || 60000); + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-e2e-user-')); + const extensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-e2e-ext-')); + writeUserSettings(userDataDir, opts.settings); + const code = findCode(); + const port = Number(process.env.WURST_OBJMOD_E2E_DEBUG_PORT || await freePort()); + + const env = { ...process.env, ...(opts.env || {}) }; + delete env.ELECTRON_RUN_AS_NODE; + + const args = [ + '--new-window', + '--skip-welcome', + '--skip-release-notes', + '--disable-updates', + '--disable-workspace-trust', + `--remote-debugging-port=${port}`, + `--user-data-dir=${userDataDir}`, + `--extensions-dir=${extensionsDir}`, + `--extensionDevelopmentPath=${root}`, + opts.projectPath, + ...(opts.files || []), + ]; + + const child = /\.cmd$/i.test(code) + ? childProcess.spawn('cmd.exe', ['/d', '/c', code, ...args], { env, stdio: ['ignore', 'ignore', 'pipe'] }) + : childProcess.spawn(code, args, { env, stdio: ['ignore', 'ignore', 'pipe'], detached: process.platform !== 'win32' }); + + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr = (stderr + chunk).slice(-8000); + }); + + let browser; + try { + const endpoint = await waitForDevtools(port, timeoutMs); + browser = await chromium.connectOverCDP(endpoint); + } catch (error) { + killTree(child); + throw new Error(`${error.message}\n--- VS Code stderr ---\n${stderr}`); + } + + const session = { + browser, + userDataDir, + extensionsDir, + get stderr() { return stderr; }, + bringToForeground: () => bringWindowToForeground(userDataDir), + + /** Every frame across every attached page — webviews included. */ + allFrames() { + return browser.contexts().flatMap((context) => context.pages()).flatMap((page) => page.frames()); + }, + + /** The workbench page (the one hosting the VS Code UI itself). */ + async workbenchPage() { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const page of browser.contexts().flatMap((context) => context.pages())) { + const isWorkbench = await page.evaluate(() => !!document.querySelector('.monaco-workbench')) + .catch(() => false); + if (isWorkbench) return page; + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error('Timed out waiting for the VS Code workbench page.'); + }, + + /** + * Waits for the frame in which `predicate` evaluates truthy. Used to find a specific webview + * by a marker it installs on `window` (e.g. the objmod editor's debug hook). + */ + async waitForFrame(predicate, description, waitMs = timeoutMs) { + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + for (const frame of session.allFrames()) { + const found = await frame.evaluate(predicate).catch(() => false); + if (found) return frame; + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + const seen = session.allFrames().map((frame) => frame.url()).join('\n '); + throw new Error(`Timed out waiting for ${description}. Frames:\n ${seen}`); + }, + + async close() { + await browser.close().catch(() => undefined); + killTree(child); + for (const dir of [userDataDir, extensionsDir]) { + try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5 }); } catch { /* locked */ } + } + }, + }; + + return session; +} + +/** Polls `fn` until `predicate` passes, then returns the value. */ +async function waitFor(fn, predicate, description, waitMs = 15000) { + const deadline = Date.now() + waitMs; + let last; + while (Date.now() < deadline) { + last = await fn().catch(() => undefined); + if (predicate(last)) return last; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${description}. Last value: ${JSON.stringify(last)}`); +} + +module.exports = { launchVsCode, waitFor }; diff --git a/e2e/harness/vscodeMock.js b/e2e/harness/vscodeMock.js new file mode 100644 index 0000000..1753365 --- /dev/null +++ b/e2e/harness/vscodeMock.js @@ -0,0 +1,137 @@ +'use strict'; + +/** + * A `vscode` module stand-in with just enough surface for the host-side preview/editor modules to + * load and build their webview HTML outside VS Code. + * + * Deliberately thin: anything a test actually asserts on (messages posted to the webview, files + * written, commands executed) is recorded on the mock so the test can read it back, and everything + * else is an inert no-op. If a module reaches for API this doesn't have, it fails loudly rather than + * silently taking a different branch. + */ + +const fs = require('fs'); +const path = require('path'); + +function fileUri(fsPath) { + const normalized = String(fsPath).replace(/\\/g, '/'); + const withSlash = normalized.startsWith('/') ? normalized : `/${normalized}`; + return { + scheme: 'file', + fsPath: path.normalize(fsPath), + path: withSlash, + toString() { return `file://${withSlash}`; }, + with(change) { return fileUri(change.path ? change.path.replace(/^\//, '') : fsPath); }, + }; +} + +function createVscodeMock(options = {}) { + const config = options.config || {}; + const recorded = { + commands: [], + info: [], + warnings: [], + errors: [], + writes: new Map(), + }; + + class EventEmitter { + constructor() { + this.listeners = []; + // `event` is a property rather than a method because consumers destructure and pass it + // around detached, exactly as they do with the real vscode.EventEmitter. + this.event = (listener) => this.subscribe(listener); + } + subscribe(listener) { + this.listeners.push(listener); + return { dispose: () => this.unsubscribe(listener) }; + } + unsubscribe(listener) { + this.listeners = this.listeners.filter((candidate) => candidate !== listener); + } + fire(value) { for (const listener of this.listeners.slice()) listener(value); } + dispose() { this.listeners = []; } + } + + const noopWatcher = () => ({ + onDidChange: () => ({ dispose() {} }), + onDidCreate: () => ({ dispose() {} }), + onDidDelete: () => ({ dispose() {} }), + dispose() {}, + }); + + const vscodeMock = { + Uri: { + file: fileUri, + parse: (value) => fileUri(String(value).replace(/^file:\/\//, '')), + joinPath: (base, ...parts) => fileUri(path.join(base.fsPath, ...parts)), + }, + workspace: { + workspaceFolders: options.workspaceFolders || [], + getWorkspaceFolder: () => (options.workspaceFolders || [])[0], + asRelativePath: (target) => String((target && target.fsPath) || target), + getConfiguration: (section) => ({ + get: (key, fallback) => { + const full = section ? `${section}.${key}` : key; + return Object.prototype.hasOwnProperty.call(config, full) ? config[full] : fallback; + }, + update: () => Promise.resolve(), + }), + onDidChangeConfiguration: () => ({ dispose() {} }), + createFileSystemWatcher: noopWatcher, + openTextDocument: () => Promise.resolve({ getText: () => '' }), + fs: { + readFile: async (uri) => new Uint8Array(fs.readFileSync(uri.fsPath)), + writeFile: async (uri, bytes) => { + recorded.writes.set(uri.toString(), Buffer.from(bytes)); + fs.writeFileSync(uri.fsPath, Buffer.from(bytes)); + }, + stat: async (uri) => ({ size: fs.statSync(uri.fsPath).size }), + delete: async (uri) => { fs.rmSync(uri.fsPath, { force: true }); }, + createDirectory: async (uri) => { fs.mkdirSync(uri.fsPath, { recursive: true }); }, + }, + }, + window: { + createOutputChannel: () => ({ appendLine() {}, append() {}, show() {}, clear() {}, dispose() {} }), + registerCustomEditorProvider: () => ({ dispose() {} }), + showInformationMessage: (message) => { recorded.info.push(message); return Promise.resolve(undefined); }, + showWarningMessage: (message) => { recorded.warnings.push(message); return Promise.resolve(undefined); }, + showErrorMessage: (message) => { recorded.errors.push(message); return Promise.resolve(undefined); }, + withProgress: (_opts, task) => task({ report() {} }, { isCancellationRequested: false }), + activeTextEditor: undefined, + visibleTextEditors: [], + }, + commands: { + registerCommand: () => ({ dispose() {} }), + executeCommand: (command, ...args) => { + recorded.commands.push({ command, args }); + // Lets a harness wire `undo`/`redo`/`workbench.action.files.save` to the real edit + // stack, so a Ctrl+Z pressed in the page actually undoes rather than only being noted. + if (options.onCommand) return Promise.resolve(options.onCommand(command, ...args)); + return Promise.resolve(undefined); + }, + }, + EventEmitter, + Disposable: class Disposable { + constructor(fn) { this.dispose = fn || (() => {}); } + static from(...items) { return { dispose: () => items.forEach((i) => i && i.dispose && i.dispose()) }; } + }, + ViewColumn: { One: 1, Two: 2, Beside: -2 }, + ConfigurationTarget: { Global: 1, Workspace: 2, WorkspaceFolder: 3 }, + ProgressLocation: { Notification: 15, Window: 10 }, + env: { + openExternal: () => Promise.resolve(true), + clipboard: { writeText: () => Promise.resolve(), readText: () => Promise.resolve('') }, + }, + Range: class Range {}, Position: class Position {}, Selection: class Selection {}, + WorkspaceEdit: class WorkspaceEdit {}, + RelativePattern: class RelativePattern { constructor(base, pattern) { this.base = base; this.pattern = pattern; } }, + ThemeIcon: class ThemeIcon { constructor(id) { this.id = id; } }, + TabInputCustom: class TabInputCustom {}, + recorded, + }; + + return vscodeMock; +} + +module.exports = { createVscodeMock, fileUri }; diff --git a/e2e/local/fixtures.js b/e2e/local/fixtures.js new file mode 100644 index 0000000..e200e4c --- /dev/null +++ b/e2e/local/fixtures.js @@ -0,0 +1,127 @@ +'use strict'; + +/** + * Fixtures for the local-only suite that drives a real VS Code window. + * + * These need an installed VS Code (and, for thumbnails, a Warcraft III install), so they are opt-in: + * set WURST_OBJMOD_E2E=1. Without it every spec here skips rather than failing, which is what keeps + * the suite safe to leave wired into the repo. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { test: base, expect } = require('@playwright/test'); + +const { launchVsCode, waitFor } = require('../harness/vscodeLauncher'); +const { repoRequire, root } = require('../harness/tsLoader'); + +const enabled = process.env.WURST_OBJMOD_E2E === '1' || process.env.WURST_LOCAL_E2E === '1'; + +/** A colour-coded value is what routes a field through the rich tooltip editor (needsColorEditor). */ +const CLIPBOARD_TEST_VALUE = '|cffffcc00Copy Paste Test|r'; + +/** + * Writes a self-contained Wurst project: a main + skin ability pair (so the header has both + * siblings to report), a colour-coded name to exercise the rich tooltip editor, imported models for + * the asset browser to thumbnail, and a .wurst file whose model string carries a "Browse model" + * CodeLens. + */ +function writeAbilityFixture() { + const { serializeObjMod } = repoRequire('casc-ts/formats'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-e2e-objmod-fixture-')); + fs.writeFileSync(path.join(dir, 'wurst.build'), 'projectName = wurst-local-e2e\n'); + + // Real model bytes, not placeholder text — the asset browser has to actually parse and render + // these, and fake bytes would only ever produce a "missing" thumbnail. + const importedModels = path.join(dir, 'imports', 'units'); + fs.mkdirSync(importedModels, { recursive: true }); + const modelFixture = path.join(root, 'wc3data', 'melon.mdx'); + if (!fs.existsSync(modelFixture)) throw new Error(`Missing model fixture: ${modelFixture}`); + for (const name of ['Footman.mdx', 'FootmanPortrait.mdx', 'CaptainFootman.mdx', 'confirmation.mdx', 'AltarOfKings.mdx']) { + fs.copyFileSync(modelFixture, path.join(importedModels, name)); + } + + const localFont = process.env.WURST_OBJMOD_E2E_FONT; + if (localFont) { + if (!fs.existsSync(localFont)) throw new Error(`WURST_OBJMOD_E2E_FONT does not exist: ${localFont}`); + fs.copyFileSync(localFont, path.join(dir, 'tooltip-e2e.ttf')); + fs.mkdirSync(path.join(dir, '.vscode')); + fs.writeFileSync( + path.join(dir, '.vscode', 'settings.json'), + JSON.stringify({ 'wurst.objModTooltipFont': 'tooltip-e2e.ttf' }), + ); + } + + const mod = (fieldId, value) => ({ fieldId, varType: 'string', level: 0, dataPt: 0, value, endToken: '\0\0\0\0' }); + fs.writeFileSync(path.join(dir, 'war3map.w3a'), serializeObjMod({ + version: 3, + ext: '.w3a', + extended: true, + origObjs: [{ baseId: 'Ahrp', newId: null, mods: [mod('anam', 'Repair E2E Override')] }], + customObjs: [{ baseId: 'Ahrp', newId: 'Z001', mods: [mod('anam', CLIPBOARD_TEST_VALUE)] }], + })); + fs.writeFileSync(path.join(dir, 'war3mapSkin.w3a'), serializeObjMod({ + version: 3, + ext: '.w3a', + extended: true, + origObjs: [{ baseId: 'Ahrp', newId: null, mods: [mod('aart', 'ReplaceableTextures\\CommandButtons\\BTNRepair.blp')] }], + customObjs: [], + })); + + const codeFile = path.join(dir, 'AssetBrowserE2e.wurst'); + fs.writeFileSync(codeFile, 'package AssetBrowserE2e\n\nconstant TEST_MODEL = "imports\\\\units\\\\Footman.mdx"\n'); + return { dir, file: path.join(dir, 'war3map.w3a'), codeFile }; +} + +const test = base.extend({ + /** + * `openVsCode({ projectPath, files, env })` launches VS Code and returns the attached session. + * Everything it opens is torn down after the test, including the temp profile directories. + */ + // eslint-disable-next-line no-empty-pattern -- Playwright requires the fixture argument to be a destructuring pattern, even when nothing is used. + openVsCode: async ({}, use) => { + const sessions = []; + const fixtures = []; + await use(async (options = {}) => { + let { projectPath, files } = options; + let codeFile; + if (!projectPath) { + const generated = writeAbilityFixture(); + fixtures.push(generated.dir); + projectPath = generated.dir; + codeFile = generated.codeFile; + files = files || (options.openCodeFile ? [generated.codeFile] : [generated.file]); + } + const session = await launchVsCode({ projectPath, files, env: options.env }); + sessions.push(session); + return Object.assign(session, { projectPath, files, codeFile }); + }); + for (const session of sessions) await session.close(); + for (const dir of fixtures) { + try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5 }); } catch { /* locked */ } + } + }, + + /** The objmod editor webview frame in a freshly launched VS Code. */ + objmodFrame: async ({ openVsCode }, use) => { + const session = await openVsCode(); + const frame = await session.waitForFrame( + () => !!window.__wurstModelThumbDebug, + 'the objmod editor webview debug hook', + ); + await use({ frame, session }); + }, +}); + +/** + * Every spec file must call this at top level. A `test.beforeEach` registered here instead would + * only attach to whichever spec file imported this module first, silently leaving the others to + * launch VS Code on an ordinary test run. + */ +function skipUnlessEnabled() { + test.skip(!enabled, 'local VS Code e2e is opt-in — set WURST_OBJMOD_E2E=1 to run it'); +} + +module.exports = { test, expect, waitFor, root, enabled, skipUnlessEnabled, CLIPBOARD_TEST_VALUE }; diff --git a/e2e/local/model-thumbnails.local.spec.js b/e2e/local/model-thumbnails.local.spec.js new file mode 100644 index 0000000..b3e9e0f --- /dev/null +++ b/e2e/local/model-thumbnails.local.spec.js @@ -0,0 +1,106 @@ +'use strict'; + +/** + * MDX model-thumbnail render benchmark: loads dist/webview/mdxViewer.js into a real browser, renders + * a deterministic still frame per fixture, and checks the result is neither blank nor black — plus an + * optional pixel-hash snapshot so a silent rendering change is caught. + * + * Local-only because the interesting fixtures are models from a Warcraft III install; the checked-in + * war3-model fixture is the default so it can still run without one. + * + * Ported from scripts/model-thumbnail-e2e.js. Knobs: WURST_MODEL_BENCH_MODELS, + * WURST_MODEL_TEXTURE_ROOTS, WURST_MODEL_SNAPSHOT_FILE, WURST_MODEL_UPDATE_SNAPSHOTS. + */ + +const fs = require('fs'); +const path = require('path'); + +const { test, expect } = require('@playwright/test'); + +const { startHarnessServer } = require('../harness/server'); +const { root } = require('../harness/tsLoader'); +const { + BENCH_PAGE_HTML, SNAPSHOT_FILE, buildFixtures, resolveModelPaths, + runModelBench, readSnapshots, writeSnapshots, +} = require('../harness/modelBench'); + +const enabled = process.env.WURST_MODEL_E2E === '1' || process.env.WURST_LOCAL_E2E === '1'; +const perModelTimeoutMs = Number(process.env.WURST_MODEL_E2E_MODEL_TIMEOUT_MS || 15000); +const updateSnapshots = process.env.WURST_MODEL_UPDATE_SNAPSHOTS === '1'; + +test.skip(!enabled, 'local model e2e is opt-in — set WURST_MODEL_E2E=1 to run it'); + +test.describe('mdx model thumbnail rendering', () => { + test.slow(); + + /** @type {{ results: any[] }} */ + let bench; + + test.beforeAll(async ({ browser }) => { + const models = resolveModelPaths(); + expect(models.length, 'no model fixtures found — set WURST_MODEL_BENCH_MODELS').toBeGreaterThan(0); + expect( + fs.existsSync(path.join(root, 'dist', 'webview', 'mdxViewer.js')), + 'missing dist/webview/mdxViewer.js — run npm run compile-web first', + ).toBe(true); + + const server = await startHarnessServer(); + const page = await browser.newPage(); + try { + await page.goto(server.publish(BENCH_PAGE_HTML), { waitUntil: 'load' }); + await page.waitForFunction(() => !!window.War3Viewer, null, { timeout: 20000 }); + const fixtures = buildFixtures(models); + const results = await page.evaluate(runModelBench, { fixtures, timeoutMs: perModelTimeoutMs }); + bench = { results }; + if (updateSnapshots) writeSnapshots(results); + for (const result of results) { + console.log( + `${result.warmup ? 'warmup' : 'bench '} ${result.name}: total=${result.totalMs.toFixed(1)}ms ` + + `load=${result.loadMs.toFixed(1)}ms textures=${result.textureMs.toFixed(1)}ms render=${result.renderMs.toFixed(1)}ms ` + + `visible=${result.visiblePixels} tex=${result.loadedTextures}/${result.requestedTextures} hash=${result.snapshotHash}` + + (result.missingTextures.length ? ` missing=${result.missingTextures.join(',')}` : ''), + ); + } + } finally { + await page.close(); + await server.close(); + } + }); + + test('every fixture renders something visible', () => { + const blank = bench.results + .filter((result) => !result.warmup && result.visiblePixels < 24) + .map((result) => `${result.name}: ${result.visiblePixels} visible pixels, ${result.alphaPixels} alpha pixels`); + expect(blank, blank.join('\n')).toEqual([]); + }); + + test('no fixture renders as an all-but-black square', () => { + // A model that loads but never gets lit produces a technically non-blank, useless thumbnail. + const dark = bench.results + .filter((result) => !result.warmup && result.avgLuma < 3 && result.maxLuma < 16) + .map((result) => `${result.name}: avg=${result.avgLuma.toFixed(1)} max=${result.maxLuma.toFixed(1)}`); + expect(dark, dark.join('\n')).toEqual([]); + }); + + test('rendered pixels match the snapshot baseline', () => { + const snapshots = readSnapshots(); + test.skip( + !Object.keys(snapshots).length, + `no snapshot baseline at ${SNAPSHOT_FILE} — set WURST_MODEL_UPDATE_SNAPSHOTS=1 to create one`, + ); + const changed = bench.results + .filter((result) => !result.warmup && snapshots[result.name]) + .filter((result) => snapshots[result.name].snapshotHash !== result.snapshotHash) + .map((result) => `${result.name}: ${snapshots[result.name].snapshotHash} -> ${result.snapshotHash}`); + expect(changed, changed.join('\n')).toEqual([]); + }); + + test('every texture a fixture asks for is one the harness could supply', () => { + // A missing texture is not necessarily a bug (stock game paths need a WC3 install), so this + // only fails when the model's own directory should have covered it. + const missing = bench.results + .filter((result) => !result.warmup && result.missingTextures.length && result.loadedTextures === 0) + .map((result) => `${result.name}: no textures resolved (${result.missingTextures.join(', ')})`); + expect(missing, missing.join('\n')).toEqual([]); + }); +}); diff --git a/e2e/local/objmod-clipboard.local.spec.js b/e2e/local/objmod-clipboard.local.spec.js new file mode 100644 index 0000000..c970c7f --- /dev/null +++ b/e2e/local/objmod-clipboard.local.spec.js @@ -0,0 +1,152 @@ +'use strict'; + +/** + * Copy / cut / paste in the objmod tooltip editor, driven by **real OS-trusted keystrokes**. + * + * This is the one thing the headless suite genuinely cannot check: `document.execCommand('copy')` + * silently no-ops for a script-synthesised event with no user gesture behind it, so calling the + * debug hooks alone can only prove the DOM/selection state is right, never that the clipboard + * actually moved. Playwright's `keyboard.press` dispatches through CDP Input, which the renderer + * treats as trusted — the same mechanism the previous hand-rolled harness used. + * + * Ported from scripts/objmod-clipboard-e2e.js. + */ + +const { test, expect, waitFor, CLIPBOARD_TEST_VALUE, skipUnlessEnabled } = require('./fixtures'); + +skipUnlessEnabled(); + +const debug = (frame, expression) => frame.evaluate(expression); + +/** Real keystroke into the VS Code window, after making sure it is the OS foreground window. */ +async function pressInWindow(session, frame, combo) { + await frame.page().bringToFront().catch(() => undefined); + session.bringToForeground(); + // SetForegroundWindow is asynchronous from the renderer's point of view — dispatching before the + // window has actually taken focus is exactly the case where Ctrl+C silently does nothing. + await frame.evaluate(() => new Promise((resolve) => { + const start = Date.now(); + const check = () => { + if (document.hasFocus() || Date.now() - start > 3000) resolve(); + else setTimeout(check, 50); + }; + check(); + })); + await frame.page().keyboard.press(combo); +} + +test.describe('objmod tooltip clipboard', () => { + test('copy + paste round-trips the text and its WC3 colour', async ({ objmodFrame }) => { + const { frame, session } = objmodFrame; + session.bringToForeground(); + + await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.selectObject('Z001')), + (value) => value === true, + 'the Z001 fixture object to be selectable', + ); + await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.openFirstTooltipField()), + (value) => value === true, + 'a tooltip field to exist and open', + ); + const original = await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.getEditableBodyText()), + (value) => typeof value === 'string' && value.length > 0, + 'the editable tooltip body to contain the fixture text', + ); + expect(original).toContain('Copy Paste Test'); + + await debug(frame, () => window.__wurstModelThumbDebug.selectAllInEditableBody()); + await pressInWindow(session, frame, 'Control+c'); + + await debug(frame, () => window.__wurstModelThumbDebug.setEditableBodyText('')); + expect(await debug(frame, () => window.__wurstModelThumbDebug.getEditableBodyText())).toBe(''); + + await debug(frame, () => window.__wurstModelThumbDebug.focusEditableBody()); + await pressInWindow(session, frame, 'Control+v'); + + const pasted = await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.getEditableBodyText()), + (value) => typeof value === 'string' && value.length > 0, + 'pasted text to appear after Ctrl+V', + ); + expect(pasted, 'copied text should come back on paste').toContain('Copy Paste Test'); + + const html = await debug(frame, () => window.__wurstModelThumbDebug.getEditableBodyHtml()); + expect(html, 'pasted text should keep its WC3 colour').toMatch(/color:\s*#ffcc00/i); + }); + + test('cut empties the box and paste restores it', async ({ objmodFrame }) => { + const { frame, session } = objmodFrame; + session.bringToForeground(); + + await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.selectObject('Z001')), + (value) => value === true, 'the Z001 fixture object to be selectable', + ); + await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.openFirstTooltipField()), + (value) => value === true, 'a tooltip field to exist and open', + ); + await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.getEditableBodyText()), + (value) => typeof value === 'string' && value.length > 0, 'the fixture text', + ); + + await debug(frame, () => window.__wurstModelThumbDebug.selectAllInEditableBody()); + await pressInWindow(session, frame, 'Control+x'); + + const afterCut = await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.getEditableBodyText()), + (value) => value === '', + 'the body to be emptied by Ctrl+X', + ); + expect(afterCut).toBe(''); + + await debug(frame, () => window.__wurstModelThumbDebug.focusEditableBody()); + await pressInWindow(session, frame, 'Control+v'); + + const restored = await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.getEditableBodyText()), + (value) => typeof value === 'string' && value.length > 0, + 'the cut text to reappear after Ctrl+V', + ); + expect(restored).toContain('Copy Paste Test'); + }); + + test('the floating toolbar anchors to the editable box, not the surrounding row', async ({ objmodFrame }) => { + const { frame } = objmodFrame; + await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.selectObject('Z001')), + (value) => value === true, 'the Z001 fixture object to be selectable', + ); + await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.openFirstTooltipField()), + (value) => value === true, 'a tooltip field to exist and open', + ); + + const toolbar = await debug(frame, () => window.__wurstModelThumbDebug.getFloatToolbarRect()); + const box = await debug(frame, () => window.__wurstModelThumbDebug.getEditableBoxRect()); + expect(toolbar, 'the floating toolbar should exist').toBeTruthy(); + expect(box, 'the editable box should exist').toBeTruthy(); + + // A real past regression: the toolbar anchored to the outer row, which also holds the source + // pill, so it drifted right by however wide that pill happened to be. + expect(Math.abs(toolbar.right - box.right)).toBeLessThan(40); + }); + + test('the fixture value is what the editor actually loaded', async ({ objmodFrame }) => { + const { frame } = objmodFrame; + await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.selectObject('Z001')), + (value) => value === true, 'the Z001 fixture object to be selectable', + ); + const rows = await waitFor( + () => debug(frame, () => window.__wurstModelThumbDebug.detailsRows()), + (value) => Array.isArray(value) && value.length > 0, + 'field rows for the fixture object', + ); + expect(rows.map((row) => row.currentValue)).toContain(CLIPBOARD_TEST_VALUE); + }); +}); diff --git a/e2e/local/objmod-thumbnails.local.spec.js b/e2e/local/objmod-thumbnails.local.spec.js new file mode 100644 index 0000000..f799ea9 --- /dev/null +++ b/e2e/local/objmod-thumbnails.local.spec.js @@ -0,0 +1,251 @@ +'use strict'; + +/** + * Model-thumbnail generation in the objmod asset browser, inside a real VS Code. + * + * Nothing here can move to the headless suite: the thumbnails come out of a real extension host + * resolving assets against a local Warcraft III install, rendered by a WebGL worker. What is being + * asserted is the *scheduling* contract from AGENTS.md — visible thumbnails drain strictly in DOM + * order, one complete lifecycle at a time, and each warm one lands inside its budget. + * + * Ported from scripts/objmod-thumbnail-e2e.js. + * + * Knobs: WURST_OBJMOD_E2E_PROJECT / _FILE (use a real map instead of the checked-in fixture), + * _SEARCH, _COUNT, _MAX_MS (default 200), _CODE_FILE (CodeLens asset-browser check). + */ + +const path = require('path'); + +const { test, expect, waitFor, root, skipUnlessEnabled } = require('./fixtures'); + +skipUnlessEnabled(); + +const MAX_THUMBNAIL_MS = Number(process.env.WURST_OBJMOD_E2E_MAX_MS || 200); +const SAMPLE_COUNT = Number(process.env.WURST_OBJMOD_E2E_COUNT || 0); +const SAMPLE_LIMIT = Number.isFinite(SAMPLE_COUNT) && SAMPLE_COUNT > 0 ? SAMPLE_COUNT : Number.POSITIVE_INFINITY; +const SEARCH_QUERY = process.env.WURST_OBJMOD_E2E_SEARCH || ''; +const CODE_ASSET_FILE = process.env.WURST_OBJMOD_E2E_CODE_FILE || ''; + +const PROJECT_PATH = process.env.WURST_OBJMOD_E2E_PROJECT || path.join(root, 'e2e'); +const OBJMOD_FILE = process.env.WURST_OBJMOD_E2E_FILE || path.join(PROJECT_PATH, 'war3map.w3u'); + +// Measure real generation, not a cache read — otherwise the timing budget proves nothing. +const THUMB_ENV = { WURST_MODEL_THUMB_DISABLE_CACHE: '1' }; + +const terminalTypes = new Set(['loaded', 'missing', 'failed']); + +function terminalKeys(state) { + const terminals = new Set(); + for (const event of state.events) if (terminalTypes.has(event.type)) terminals.add(event.key); + for (const slot of state.visible || []) if (slot.loaded || slot.missing) terminals.add(slot.key); + return terminals; +} + +function durationsByKey(events) { + const starts = new Map(); + const out = new Map(); + for (const event of events) { + if (event.type === 'host-start' && !starts.has(event.key)) starts.set(event.key, event.at); + if (terminalTypes.has(event.type) && starts.has(event.key) && !out.has(event.key)) { + out.set(event.key, event.at - starts.get(event.key)); + } + } + return out; +} + +/** The renderer mode a thumbnail used — the first of each mode pays a one-off warm-up cost. */ +function rendererModeForKey(events, key) { + const parsed = events.find((event) => event.key === key && event.type === 'profile:worker-parsed'); + if (!parsed || !parsed.detail) return 'unknown'; + try { return JSON.parse(parsed.detail).isHD ? 'hd' : 'sd'; } catch { return 'unknown'; } +} + +function describeEvent(event) { + const phase = event.type.replace(/^profile:/, ''); + const elapsed = event.elapsedMs == null ? '' : `@${event.elapsedMs}ms`; + const detail = event.detail ? ` ${event.detail}` : ''; + const reason = event.reason ? ` reason=${event.reason}` : ''; + return phase + elapsed + detail + reason; +} + +function profileForKey(events, key) { + return events + .filter((event) => event.key === key && + (event.type === 'host-start' || event.type === 'render-start' || + terminalTypes.has(event.type) || event.type.startsWith('profile:'))) + .map(describeEvent); +} + +async function openAssetBrowser(frame) { + await frame.evaluate(() => window.__wurstModelThumbDebug.openModelAssetBrowser()); + if (SEARCH_QUERY) { + await frame.evaluate((query) => window.__wurstModelThumbDebug.searchModelAssetBrowser(query), SEARCH_QUERY); + } +} + +/** A visible slot must be either pending or already finished — never idle in between. */ +function idleVisibleSlots(state, visible, initialKeys) { + const terminals = terminalKeys(state); + return visible + .filter((slot) => initialKeys.includes(slot.key) && !terminals.has(slot.key) && !slot.pending) + .map((slot) => `${slot.key} was neither pending nor terminal while visible`); +} + +/** + * Watches the asset browser until every thumbnail that was visible at the start reached a terminal + * state, recording any slot that went idle on screen along the way. + */ +async function drainVisibleThumbnails(frame, timeoutMs) { + let initialKeys = []; + const violations = []; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const state = await frame.evaluate(() => window.__wurstModelThumbDebug.state()); + const visible = state.visible.filter((slot) => slot.visible).slice(0, SAMPLE_LIMIT); + if (!initialKeys.length && visible.length >= Math.min(SAMPLE_LIMIT, 8)) { + initialKeys = visible.map((slot) => slot.key); + } + if (initialKeys.length) { + violations.push(...idleVisibleSlots(state, visible, initialKeys)); + const terminals = terminalKeys(state); + if (initialKeys.every((key) => terminals.has(key))) return { state, initialKeys, violations }; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out draining thumbnails (observed ${initialKeys.length} visible slots).`); +} + +test.describe('objmod model thumbnails', () => { + test.slow(); + + test('visible thumbnails finish in DOM order, one lifecycle at a time', async ({ openVsCode }) => { + const session = await openVsCode({ projectPath: PROJECT_PATH, files: [OBJMOD_FILE], env: THUMB_ENV }); + const frame = await session.waitForFrame(() => !!window.__wurstModelThumbDebug, 'the objmod webview'); + await openAssetBrowser(frame); + + const { state, initialKeys, violations } = await drainVisibleThumbnails(frame, 90000); + + expect(violations, violations.join('\n')).toEqual([]); + + const terminalOrder = []; + const seen = new Set(); + for (const event of state.events) { + if (!terminalTypes.has(event.type) || !initialKeys.includes(event.key) || seen.has(event.key)) continue; + seen.add(event.key); + terminalOrder.push(event.key); + } + expect(terminalOrder.slice(0, initialKeys.length), 'terminal order must match visible DOM order') + .toEqual(initialKeys); + }); + + test('every visible thumbnail renders, and warm ones stay inside the time budget', async ({ openVsCode }) => { + const session = await openVsCode({ projectPath: PROJECT_PATH, files: [OBJMOD_FILE], env: THUMB_ENV }); + const frame = await session.waitForFrame(() => !!window.__wurstModelThumbDebug, 'the objmod webview'); + await openAssetBrowser(frame); + + const { state, initialKeys } = await drainVisibleThumbnails(frame, 90000); + expect(state.worker && state.worker.state, 'the thumbnail worker should have started').toBe('ready'); + + const bySlot = new Map(state.visible.map((slot) => [slot.key, slot])); + const notRendered = initialKeys.filter((key) => !(bySlot.get(key) || {}).loaded); + expect(notRendered.map((key) => `${key}: ${JSON.stringify(profileForKey(state.events, key))}`)).toEqual([]); + + // The first thumbnail of each renderer mode pays a one-off warm-up, so it is excluded from + // the per-thumbnail budget rather than being allowed to hide a regression in the rest. + const warmupKeys = new Set(); + const warmedModes = new Set(); + for (const key of initialKeys) { + if (!state.events.some((event) => event.key === key && event.type === 'render-start')) continue; + const mode = rendererModeForKey(state.events, key); + if (warmedModes.has(mode)) continue; + warmedModes.add(mode); + warmupKeys.add(key); + } + + const durations = durationsByKey(state.events); + const overBudget = []; + for (const key of initialKeys.filter((key) => !warmupKeys.has(key))) { + const ms = durations.get(key); + if (typeof ms !== 'number') overBudget.push(`${key}: missing duration`); + else if (ms > MAX_THUMBNAIL_MS) overBudget.push(`${key}: ${ms}ms exceeded the ${MAX_THUMBNAIL_MS}ms budget`); + } + expect(overBudget, overBudget.join('\n')).toEqual([]); + }); + + test('iconless model objects use thumbnail slots rather than inert placeholders', async ({ objmodFrame }) => { + const { frame } = objmodFrame; + const state = await waitFor( + () => frame.evaluate(() => window.__wurstModelThumbDebug.state()), + (value) => !!value, + 'objmod editor state', + ); + expect(state.inert3dPlaceholders).toBe(0); + }); + + test('the narrow layout keeps both panes usable and unforcing it restores side-by-side', async ({ objmodFrame }) => { + const { frame } = objmodFrame; + await frame.evaluate(() => window.__wurstModelThumbDebug.forceNarrowLayout(true)); + const stacked = await waitFor( + () => frame.evaluate(() => window.__wurstModelThumbDebug.layout()), + (value) => value && value.listVisible && value.detailsVisible && value.stacked, + 'the stacked narrow layout', + ); + expect(stacked.details.height).toBeGreaterThanOrEqual(200); + + await frame.evaluate(() => window.__wurstModelThumbDebug.forceNarrowLayout(false)); + const sideBySide = await waitFor( + () => frame.evaluate(() => window.__wurstModelThumbDebug.layout()), + (value) => value && value.listVisible && value.detailsVisible && !value.stacked, + 'the side-by-side layout', + ); + expect(sideBySide.list.right).toBeLessThanOrEqual(sideBySide.details.left + 8); + expect(sideBySide.list.width).toBeLessThanOrEqual(sideBySide.editor.width * 0.5); + }); + + test('both sibling object files are reported in the header', async ({ objmodFrame }) => { + const { frame } = objmodFrame; + const state = await waitFor( + () => frame.evaluate(() => window.__wurstModelThumbDebug.state()), + (value) => value && value.fileInfo, + 'objmod file info', + ); + expect(state.fileInfo.mainName).toBe('war3map.w3a'); + expect(state.fileInfo.skinName).toBe('war3mapSkin.w3a'); + }); + + test('a "Browse model" CodeLens opens the asset browser with relevance-sorted results', async ({ openVsCode }) => { + // Without an override, the generated fixture supplies its own AssetBrowserE2e.wurst. + const session = CODE_ASSET_FILE + ? await openVsCode({ projectPath: PROJECT_PATH, files: [CODE_ASSET_FILE], env: THUMB_ENV }) + : await openVsCode({ openCodeFile: true, env: THUMB_ENV }); + const workbench = await session.workbenchPage(); + session.bringToForeground(); + await workbench.bringToFront().catch(() => undefined); + + // The clickable region is the anchor inside the decoration, not the decoration box itself — + // a centre-click on the container lands beside it and silently does nothing. + const lens = workbench.locator('.codelens-decoration a', { hasText: 'Browse model' }).first(); + await lens.waitFor({ state: 'visible', timeout: 60000 }); + await lens.click(); + + const browser = await session.waitForFrame( + () => !!window.__wurstCodeAssetBrowserDebug, + 'the CodeLens-launched asset browser', + 30000, + ); + await browser.evaluate(() => window.__wurstCodeAssetBrowserDebug.search('footman')); + const state = await waitFor( + () => browser.evaluate(() => window.__wurstCodeAssetBrowserDebug.state()), + (value) => value && value.query === 'footman' && Array.isArray(value.results) && value.results.length > 0, + 'footman asset search results', + ); + + expect(state.activeTab, 'a model string should open the Models tab').toBe('model'); + expect(state.results.some((entry) => /footman/i.test(`${entry.label} ${entry.value}`))).toBe(true); + // Fuzzy matching must not drag in unrelated results just because they share a few letters. + expect(state.results.filter((entry) => !/footm[ae]n/i.test(`${entry.label} ${entry.value}`))).toEqual([]); + for (let i = 1; i < state.results.length; i++) { + expect(state.results[i - 1].score).toBeLessThanOrEqual(state.results[i].score); + } + }); +}); diff --git a/e2e/specs/objmod-browse.spec.js b/e2e/specs/objmod-browse.spec.js new file mode 100644 index 0000000..efb1fc1 --- /dev/null +++ b/e2e/specs/objmod-browse.spec.js @@ -0,0 +1,134 @@ +'use strict'; + +/** + * Browse list: rendering, search, selection, and the state that has to survive a webview reload. + */ + +const { test, expect } = require('../fixtures'); + +/** Rows only exist for expanded branches, so "all objects" means expanding everything first. */ +async function expandAll(page) { + for (let i = 0; i < 6; i++) { + const collapsed = page.locator('#tree [aria-expanded="false"]'); + const count = await collapsed.count(); + if (!count) break; + for (let n = 0; n < count; n++) await collapsed.nth(0).click(); + } +} + +test('renders a grouped tree and selects the first object with its field rows', async ({ openObjMod }) => { + const { page, host, pageErrors } = await openObjMod(); + + await expect(page.locator('#tree .object-row').first()).toBeVisible(); + await expect(page.locator('.md-meta')).toContainText('123 objects'); + + // The first object is selected on open and its fields are requested from the host, not shipped + // in the initial payload — that laziness is the whole point of the details protocol. + await expect(page.locator('#details .table-wrap tbody tr')).not.toHaveCount(0); + expect(host.posted.some((m) => m.type === 'objectDetailsLoaded')).toBe(true); + expect(pageErrors).toEqual([]); +}); + +test('search filters the tree, reports a match count, and the clear button restores it', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await expandAll(page); + const allRows = await page.locator('#tree .object-row').count(); + expect(allRows).toBe(123); + + await page.fill('#search', 'peasant'); + await expect(page.locator('#search-match')).toHaveText(/^\d+ of 123$/); + const matched = Number((await page.locator('#search-match').textContent()).split(' ')[0]); + expect(matched).toBeGreaterThan(0); + expect(matched).toBeLessThan(allRows); + for (const name of await page.locator('#tree .object-row .object-name').allTextContents()) { + expect(name.toLowerCase()).toContain('peasant'); + } + + await page.click('#search-clear'); + await expect(page.locator('#search')).toHaveValue(''); + await expect(page.locator('#search-match')).toHaveText(''); + await expandAll(page); + expect(await page.locator('#tree .object-row').count()).toBe(allRows); +}); + +test('search finds an object by its rawcode', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await expandAll(page); + const allRows = await page.locator('#tree .object-row').count(); + + // The matcher is fuzzy (see matchScore in objectTree.ts), so a rawcode query narrows rather than + // pinpoints — what matters is that the exact object survives the filter and the list shrinks. + await page.fill('#search', 'h004'); + await expandAll(page); + const matched = await page.locator('#tree .object-row').count(); + expect(matched).toBeLessThan(allRows); + await expect(page.locator('#tree .object-row .object-id', { hasText: 'h004' }).first()).toBeVisible(); +}); + +test('a query that excludes the selected object moves the selection into the results', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await page.fill('#search', 'peasant'); + await expect(page.locator('#tree .object-row.active')).toHaveCount(1); + const activeName = await page.locator('#tree .object-row.active .object-name').textContent(); + expect(activeName.toLowerCase()).toContain('peasant'); +}); + +test('clicking an object switches the details panel and tells the host the new selection', async ({ openObjMod }) => { + const { page, host } = await openObjMod(); + await page.fill('#search', 'militia'); + const row = page.locator('#tree .object-row').first(); + const label = await row.locator('.object-name').textContent(); + await row.click(); + + await expect(page.locator('#details .details-title')).toContainText(label.trim()); + await expect(page.locator('#details .table-wrap tbody tr')).not.toHaveCount(0); + + // The host persists selection by stable rawcode identity, not by array index (see + // rememberSelection/objModSelectionPathKey) — that is what survives Git reordering the file. + await expect + .poll(() => host.doc.selectedIdentity, { message: 'host should have stored the selection identity' }) + .toBeTruthy(); +}); + +test('selection, search text and density survive a webview reload', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod(); + + await page.fill('#search', 'militia'); + await page.locator('#tree .object-row').first().click(); + await expect(page.locator('#details .table-wrap tbody tr')).not.toHaveCount(0); + await page.click('#density-toggle'); + await expect(page.locator('body')).toHaveClass(/density-cozy/); + const selectedBefore = await page.locator('#tree .object-row.active .object-id').textContent(); + + // Same thing the host's own external-change auto-reload does: rebuild the HTML and re-navigate. + await gotoHtml(await host.rerender()); + + await expect(page.locator('#search')).toHaveValue('militia'); + await expect(page.locator('body')).toHaveClass(/density-cozy/); + await expect(page.locator('#tree .object-row.active .object-id')).toHaveText(selectedBefore); +}); + +test('collapsing a tree branch persists across a reload', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod(); + + const heading = page.locator('#tree [aria-expanded="true"]').first(); + const nodeKey = await heading.getAttribute('data-node'); + await heading.click(); + await expect(page.locator(`#tree [data-node="${nodeKey}"]`)).toHaveAttribute('aria-expanded', 'false'); + + await gotoHtml(await host.rerender()); + await expect(page.locator(`#tree [data-node="${nodeKey}"]`)).toHaveAttribute('aria-expanded', 'false'); +}); + +test('tree scroll position is restored on reload', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod(); + await expandAll(page); + + await page.locator('#tree').evaluate((el) => { el.scrollTop = 400; el.dispatchEvent(new Event('scroll')); }); + await expect.poll(() => page.locator('#tree').evaluate((el) => el.scrollTop)).toBeGreaterThan(300); + + await gotoHtml(await host.rerender()); + await expect + .poll(() => page.locator('#tree').evaluate((el) => el.scrollTop), { message: 'tree scroll should be restored' }) + .toBeGreaterThan(300); +}); diff --git a/e2e/specs/objmod-fields.spec.js b/e2e/specs/objmod-fields.spec.js new file mode 100644 index 0000000..b3d9313 --- /dev/null +++ b/e2e/specs/objmod-fields.spec.js @@ -0,0 +1,230 @@ +'use strict'; + +/** + * Field table: filters, the technical view, and the full edit round-trip — webview edit -> host + * document edit -> undo/redo -> save -> bytes on disk that re-parse to the edited value. + * + * Assertions deliberately key off field *ids* and values that come from the fixture file, never off + * game-data labels: labels resolve through WorldEditStrings in CASC, so they differ between a machine + * with Warcraft III installed and CI, while ids and overrides are identical everywhere. + */ + +const { test, expect } = require('../fixtures'); +const { repoRequire } = require('../harness/tsLoader'); + +const { parseObjMod } = repoRequire('casc-ts/formats'); + +/** Selects a custom object by rawcode and waits for its field rows to land. */ +async function selectObject(page, rawcode) { + await page.fill('#search', rawcode); + await page.locator('#tree .object-row', { has: page.locator('.object-id', { hasText: rawcode }) }).first().click(); + await expect(page.locator('#details .table-wrap tbody tr')).not.toHaveCount(0); +} + +/** The row for a given field id, found via technical mode's id column. */ +function rowForField(page, fieldId) { + return page.locator('#details tbody tr', { has: page.locator(`td.id:text-is("${fieldId}")`) }); +} + +test('technical mode swaps in the id/type columns and back', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await expect(page.locator('#details thead th')).toHaveText(['Field', 'Value']); + + await page.check('#technical-toggle'); + await expect(page.locator('#details thead th')).toHaveText(['Field', 'Label', 'Group', 'Type', 'Value']); + await expect(page.locator('#details tbody td.id').first()).toHaveText(/^[a-zA-Z0-9]{4}$/); + + await page.uncheck('#technical-toggle'); + await expect(page.locator('#details thead th')).toHaveText(['Field', 'Value']); +}); + +test('"modified only" narrows the table to overridden rows', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await selectObject(page, 'h004'); + + const total = await page.locator('#details tbody tr:not(.category-row)').count(); + const overridden = await page.locator('#details tbody tr.overridden').count(); + expect(overridden).toBeGreaterThan(0); + expect(overridden).toBeLessThan(total); + + await page.check('#hide-unmodified-toggle'); + const visible = page.locator('#details tbody tr:not(.category-row):not(.hidden)'); + await expect(visible).toHaveCount(overridden); + for (const cls of await visible.evaluateAll((rows) => rows.map((r) => r.className))) { + expect(cls).toContain('overridden'); + } +}); + +test('"hide empty" removes rows whose value is blank or WC3\'s "-" placeholder', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await selectObject(page, 'h004'); + + const emptyRows = page.locator('#details tbody tr[data-empty="1"]'); + expect(await emptyRows.count()).toBeGreaterThan(0); + + await page.check('#hide-empty-toggle'); + await expect(page.locator('#details tbody tr[data-empty="1"]:not(.hidden)')).toHaveCount(0); + await expect(page.locator('#details tbody tr[data-empty="0"]:not(.hidden)')).not.toHaveCount(0); +}); + +test('field search filters rows and reports how many matched', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await selectObject(page, 'h004'); + await page.check('#technical-toggle'); + + const before = await page.locator('#details tbody tr:not(.category-row):not(.hidden)').count(); + await page.fill('#field-search', 'ugol'); + + // The field search is fuzzy (same scorer as the object search), so it narrows rather than + // pinpoints — the contract is that it shrinks the table, keeps the exact match, and says how many. + await expect(page.locator('#field-match')).toHaveText(/^\d+ match(es)?$/); + const visible = page.locator('#details tbody tr:not(.category-row):not(.hidden)'); + const after = await visible.count(); + expect(after).toBeGreaterThan(0); + expect(after).toBeLessThan(before); + await expect(visible.locator('td.id:text-is("ugol")')).toHaveCount(1); + + await page.fill('#field-search', ''); + await expect(page.locator('#details tbody tr:not(.category-row):not(.hidden)')).toHaveCount(before); +}); + +test('the category filter hides a whole category and shows a count badge', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await selectObject(page, 'h004'); + + await page.click('#cat-filter-btn'); + await expect(page.locator('#cat-filter-pop')).toBeVisible(); + const firstCat = page.locator('#cat-filter-pop input[type="checkbox"]').first(); + const catKey = await firstCat.getAttribute('data-cat'); + await firstCat.uncheck(); + + await expect(page.locator(`#details tbody tr[data-cat="${catKey}"]:not(.hidden)`)).toHaveCount(0); + await expect(page.locator('#cat-filter-btn')).toContainText('1'); + + await page.click('#cat-filter-none'); + await expect(page.locator('#details tbody tr:not(.category-row):not(.hidden)')).toHaveCount(0); + await page.click('#cat-filter-all'); + await expect(page.locator('#details tbody tr:not(.category-row):not(.hidden)')).not.toHaveCount(0); +}); + +test('editing an int field posts the edit, marks the document dirty, and survives undo/redo', async ({ openObjMod }) => { + const { page, host } = await openObjMod(); + await selectObject(page, 'h004'); + await page.check('#technical-toggle'); + + const goldRow = rowForField(page, 'ugol'); + await expect(goldRow).toHaveCount(1); + await goldRow.locator('.cell-edit').click(); + + const input = goldRow.locator('input.num-input'); + await expect(input).toBeVisible(); + await expect(input).toHaveValue('25'); + await input.fill('137'); + await input.blur(); + + await expect.poll(() => host.isDirty, { message: 'the host document should be dirty' }).toBe(true); + await expect(page.locator('#editable-badge')).toHaveText('● unsaved'); + expect(host.editLabels).toEqual(['Edit ugol']); + await expect(goldRow.locator('.cell-edit-val')).toContainText('137'); + + host.undo(); + await expect.poll(() => host.isDirty).toBe(false); + await expect(goldRow.locator('.cell-edit-val')).toContainText('25'); + + host.redo(); + await expect.poll(() => host.isDirty).toBe(true); + await expect(goldRow.locator('.cell-edit-val')).toContainText('137'); +}); + +test('the number steppers respect the field varType', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await selectObject(page, 'h004'); + await page.check('#technical-toggle'); + + // ua1c (Attack 1 cooldown) is an `unreal`, so it steps in 0.05 and clamps at zero rather than + // going negative; ugol is an `int` and steps by whole numbers. + const cooldown = rowForField(page, 'ua1c'); + await cooldown.locator('.cell-edit').click(); + const cooldownInput = cooldown.locator('input.num-input'); + await expect(cooldownInput).toHaveAttribute('data-num-step', '0.05'); + await cooldownInput.fill('0'); + await cooldown.locator('.num-step[data-dir="-1"]').click(); + await expect(cooldownInput).toHaveValue('0'); + + const gold = rowForField(page, 'ugol'); + await gold.locator('.cell-edit').click(); + const goldInput = gold.locator('input.num-input'); + await expect(goldInput).toHaveAttribute('data-num-step', '1'); + await goldInput.fill('10'); + await gold.locator('.num-step[data-dir="1"]').click(); + await expect(goldInput).toHaveValue('11'); +}); + +test('saving writes bytes that re-parse to the edited value', async ({ openObjMod }) => { + const { page, host } = await openObjMod(); + await selectObject(page, 'h004'); + await page.check('#technical-toggle'); + + const goldRow = rowForField(page, 'ugol'); + await goldRow.locator('.cell-edit').click(); + await goldRow.locator('input.num-input').fill('4242'); + await goldRow.locator('input.num-input').blur(); + await expect.poll(() => host.isDirty).toBe(true); + + await host.save(); + expect(host.isDirty).toBe(false); + + const reparsed = parseObjMod(host.readFile(), '.w3u'); + const entry = reparsed.customObjs.find((obj) => obj.newId === 'h004'); + expect(entry, 'h004 should still exist after the save').toBeTruthy(); + const gold = entry.mods.find((mod) => mod.fieldId === 'ugol'); + expect(gold, 'the edited field should be written as an override').toBeTruthy(); + expect(String(gold.value)).toBe('4242'); +}); + +test('Ctrl+S in the webview saves through the host', async ({ openObjMod }) => { + const { page, host } = await openObjMod(); + await selectObject(page, 'h004'); + await page.check('#technical-toggle'); + + const row = rowForField(page, 'ufoo'); + await row.locator('.cell-edit').click(); + await row.locator('input.num-input').fill('9'); + // No blur: Ctrl+S has to commit the focused editor itself (commitActiveEditor) before saving, + // otherwise the in-progress edit is silently dropped. + await page.keyboard.press('Control+s'); + + await expect.poll(() => host.isDirty, { message: 'Ctrl+S should have saved' }).toBe(false); + const entry = parseObjMod(host.readFile(), '.w3u').customObjs.find((obj) => obj.newId === 'h004'); + expect(String(entry.mods.find((mod) => mod.fieldId === 'ufoo').value)).toBe('9'); +}); + +test('a rawcode reference chip inside a field jumps to that object', async ({ openObjMod }) => { + const { page } = await openObjMod(); + await selectObject(page, 'h004'); + await page.check('#technical-toggle'); + + // ubui (Structures Built) is a rawcode list; every id the file also defines becomes a jump chip. + const chip = rowForField(page, 'ubui').locator('.resolved-chip[data-jump]').first(); + await expect(chip).toBeVisible(); + const target = await chip.getAttribute('data-jump'); + await chip.click(); + + await expect(page.locator(`#tree .object-row[data-key="${target}"]`)).toHaveClass(/active/); +}); + +test('field-table state (technical, filters) is restored after a reload', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod(); + await selectObject(page, 'h004'); + await page.check('#technical-toggle'); + await page.check('#hide-unmodified-toggle'); + await page.fill('#field-search', 'ugol'); + const filtered = await page.locator('#details tbody tr:not(.category-row):not(.hidden)').count(); + + await gotoHtml(await host.rerender()); + + await expect(page.locator('#technical-toggle')).toBeChecked(); + await expect(page.locator('#hide-unmodified-toggle')).toBeChecked(); + await expect(page.locator('#field-search')).toHaveValue('ugol'); + await expect(page.locator('#details tbody tr:not(.category-row):not(.hidden)')).toHaveCount(filtered); +}); diff --git a/e2e/specs/objmod-layout.spec.js b/e2e/specs/objmod-layout.spec.js new file mode 100644 index 0000000..c9d1e8c --- /dev/null +++ b/e2e/specs/objmod-layout.spec.js @@ -0,0 +1,134 @@ +'use strict'; + +/** + * Layout contracts that only a real browser can check: the splitter's clamping and ARIA, the + * side-by-side -> stacked fallback driven by the ResizeObserver, the two density scales, and the + * settings-driven tooltip width. + * + * These assert on computed layout (bounding boxes, resolved CSS variables) rather than on markup, so + * a CSS change that visually breaks the editor fails here even when the DOM is untouched. + */ + +const { test, expect } = require('../fixtures'); + +const LIST_MIN_PX = 130; +const LIST_MAX_RATIO = 0.46; + +const listWidth = (page) => page.locator('.object-list').evaluate((el) => el.getBoundingClientRect().width); +const editorWidth = (page) => page.locator('#object-editor').evaluate((el) => el.getBoundingClientRect().width); + +test('the browse list sits beside the details pane at a normal width', async ({ openObjMod }) => { + const { page } = await openObjMod(); + const list = await page.locator('.object-list').boundingBox(); + const details = await page.locator('#details').boundingBox(); + + expect(details.x).toBeGreaterThanOrEqual(list.x + list.width - 1); + await expect(page.locator('#object-editor')).not.toHaveClass(/narrow/); + expect(list.width).toBeGreaterThan(LIST_MIN_PX); +}); + +test('the splitter resizes with the keyboard, clamps, and reports its range via ARIA', async ({ openObjMod }) => { + const { page } = await openObjMod(); + const splitter = page.locator('#splitter'); + + await splitter.focus(); + const before = await listWidth(page); + await splitter.press('ArrowRight'); + await splitter.press('ArrowRight'); + await expect.poll(() => listWidth(page)).toBeGreaterThan(before); + + // Home/End must land exactly on the documented bounds, not merely near them. + await splitter.press('Home'); + await expect.poll(() => listWidth(page)).toBeCloseTo(LIST_MIN_PX, 0); + await expect(splitter).toHaveAttribute('aria-valuenow', String(LIST_MIN_PX)); + + await splitter.press('End'); + const max = Math.round((await editorWidth(page)) * LIST_MAX_RATIO); + await expect.poll(async () => Math.round(await listWidth(page))).toBeCloseTo(max, -1); + await expect(splitter).toHaveAttribute('aria-valuemin', String(LIST_MIN_PX)); + await expect(splitter).toHaveAttribute('aria-valuemax', String(max)); +}); + +test('a dragged splitter width is persisted and restored', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod(); + const splitter = page.locator('#splitter'); + const box = await splitter.boundingBox(); + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + 90, box.y + box.height / 2, { steps: 8 }); + await page.mouse.up(); + + const dragged = await listWidth(page); + expect(dragged).toBeGreaterThan(LIST_MIN_PX); + + await gotoHtml(await host.rerender()); + await expect.poll(() => listWidth(page)).toBeCloseTo(dragged, 0); +}); + +test('a very narrow editor stacks the list above the details pane', async ({ openObjMod, page }) => { + await openObjMod(); + // Below NARROW_LAYOUT_PX (440) the ResizeObserver flips the editor into its stacked fallback. + await page.setViewportSize({ width: 400, height: 800 }); + + await expect(page.locator('#object-editor')).toHaveClass(/narrow/); + const list = await page.locator('.object-list').boundingBox(); + const details = await page.locator('#details').boundingBox(); + expect(details.y).toBeGreaterThanOrEqual(list.y + list.height - 1); + + // ...and widening again restores side-by-side, rather than sticking in the fallback. + await page.setViewportSize({ width: 1280, height: 800 }); + await expect(page.locator('#object-editor')).not.toHaveClass(/narrow/); +}); + +test('the density toggle switches the spacing scale and persists it', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod(); + const toggle = page.locator('#density-toggle'); + const rowHeight = () => page.locator('#tree .object-row').first().evaluate((el) => el.getBoundingClientRect().height); + + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + const compact = await rowHeight(); + + await toggle.click(); + await expect(page.locator('body')).toHaveClass(/density-cozy/); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect.poll(rowHeight, { message: 'cozy rows should be taller than compact ones' }).toBeGreaterThan(compact); + + await gotoHtml(await host.rerender()); + await expect(page.locator('body')).toHaveClass(/density-cozy/); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + + await toggle.click(); + await expect(page.locator('body')).not.toHaveClass(/density-cozy/); + await expect.poll(rowHeight).toBeCloseTo(compact, 0); +}); + +test('wurst.objModTooltipWidth drives the tooltip box width and is clamped to its setting range', async ({ openObjMod }) => { + const { page } = await openObjMod({ config: { 'wurst.objModTooltipWidth': 420 } }); + await expect(page.locator('#tree .object-row').first()).toBeVisible(); + + const width = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--wc3-tip-width').trim()); + expect(width).toBe('420px'); +}); + +test('an out-of-range tooltip width setting falls back to the allowed maximum', async ({ openObjMod }) => { + const { page } = await openObjMod({ config: { 'wurst.objModTooltipWidth': 99999 } }); + await expect(page.locator('#tree .object-row').first()).toBeVisible(); + + const width = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--wc3-tip-width').trim()); + expect(width).toBe('1200px'); +}); + +test('the field table never forces the page into a horizontal scroll', async ({ openObjMod, page }) => { + await openObjMod(); + await expect(page.locator('#details .table-wrap tbody tr')).not.toHaveCount(0); + + for (const width of [1280, 900, 640, 420]) { + await page.setViewportSize({ width, height: 800 }); + const overflow = await page.evaluate(() => + document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow, `page should not scroll horizontally at ${width}px`).toBeLessThanOrEqual(1); + } +}); diff --git a/e2e/specs/objmod-tooltip-editor.spec.js b/e2e/specs/objmod-tooltip-editor.spec.js new file mode 100644 index 0000000..d8b2432 --- /dev/null +++ b/e2e/specs/objmod-tooltip-editor.spec.js @@ -0,0 +1,227 @@ +'use strict'; + +/** + * The in-place WC3 tooltip editor: the rich/raw editing box, its floating toolbar, and the colour + * palette (presets, colours already used in the text, and the saved custom colours that round-trip + * through the host's globalState). + */ + +const { test, expect } = require('../fixtures'); +const { repoRequire } = require('../harness/tsLoader'); + +const { parseObjMod } = repoRequire('casc-ts/formats'); + +const CUSTOM_COLORS_KEY = 'wurst.objModCustomColors.v1'; + +async function selectObject(page, rawcode) { + await page.fill('#search', rawcode); + await page.locator('#tree .object-row', { has: page.locator('.object-id', { hasText: rawcode }) }).first().click(); + await expect(page.locator('#details .table-wrap tbody tr')).not.toHaveCount(0); +} + +/** + * Opens a colour-capable field's in-place editor and returns its parts. Picks a field that already + * has text: an empty contenteditable box has no layout height, which makes visibility assertions + * meaningless and hides real regressions behind "element not visible". + */ +async function openTooltipEditor(page, rawcode = 'h004') { + await selectObject(page, rawcode); + const collapsed = page.locator('#details .tt-collapsed[data-mi]') + .filter({ hasNot: page.locator('.tt-empty') }) + .first(); + await expect(collapsed).toBeVisible(); + await collapsed.click(); + const body = page.locator('.tt-collapsed-body[contenteditable="true"]'); + await expect(body).toBeVisible(); + return { collapsed, body, toolbar: page.locator('.tt-float-toolbar') }; +} + +test('clicking a tooltip field opens an in-place editor with a floating toolbar', async ({ openObjMod }) => { + const { page } = await openObjMod(); + const { collapsed, body, toolbar } = await openTooltipEditor(page); + + await expect(collapsed).toHaveClass(/tt-editing/); + await expect(toolbar).toBeVisible(); + await expect(toolbar.locator('.tt-raw-toggle')).toHaveAttribute('aria-pressed', 'false'); + await expect(body).toHaveAttribute('contenteditable', 'true'); + + // The toolbar floats over the page but must stay beside the box it is editing, not off-screen. + const boxRect = await page.locator('.tt-collapsed-box').first().boundingBox(); + const toolbarRect = await toolbar.boundingBox(); + expect(toolbarRect.x + toolbarRect.width).toBeGreaterThan(0); + expect(toolbarRect.y).toBeLessThan(boxRect.y + boxRect.height + 200); +}); + +test('the Raw toggle swaps to a textarea holding the WC3 source and back', async ({ openObjMod }) => { + const { page } = await openObjMod(); + const { body, toolbar } = await openTooltipEditor(page); + + await body.click(); + await page.keyboard.type('Hello'); + await toolbar.locator('.tt-raw-toggle').click(); + + const raw = page.locator('textarea.tt-collapsed-raw'); + await expect(raw).toBeVisible(); + await expect(body).toBeHidden(); + await expect(toolbar.locator('.tt-raw-toggle')).toHaveAttribute('aria-pressed', 'true'); + expect(await raw.inputValue()).toContain('Hello'); + + await toolbar.locator('.tt-raw-toggle').click(); + await expect(body).toBeVisible(); + await expect(raw).toBeHidden(); + await expect(body).toContainText('Hello'); +}); + +test('typing colour codes in raw mode updates the "used colours" swatches live', async ({ openObjMod }) => { + const { page } = await openObjMod(); + const { toolbar } = await openTooltipEditor(page); + + const used = toolbar.locator('.tt-used-colors'); + await toolbar.locator('.tt-raw-toggle').click(); + const raw = page.locator('textarea.tt-collapsed-raw'); + + await raw.fill('|cff00ff00Green|r and |cffff0000Red|r'); + await expect(used.locator('.tt-used-sw')).toHaveCount(2); + await expect(used.locator('.tt-used-sw').nth(0)).toHaveAttribute('data-color', '00ff00'); + await expect(used.locator('.tt-used-sw').nth(1)).toHaveAttribute('data-color', 'ff0000'); + + // Adding a third colour has to show up without reopening the editor — the whole point of the + // live refresh (the group used to reflect only the value from when editing started). + await raw.fill('|cff00ff00Green|r |cffff0000Red|r |cff0000ffBlue|r'); + await expect(used.locator('.tt-used-sw')).toHaveCount(3); + + // ...and removing them again has to empty *and* hide the group, not leave a stray divider. + await raw.fill('no colours here'); + await expect(used.locator('.tt-used-sw')).toHaveCount(0); + await expect(used).toBeHidden(); +}); + +test('an escaped "||" is not mistaken for a colour code', async ({ openObjMod }) => { + const { page } = await openObjMod(); + const { toolbar } = await openTooltipEditor(page); + await toolbar.locator('.tt-raw-toggle').click(); + + await page.locator('textarea.tt-collapsed-raw').fill('||cffabcdefnot a colour |cff123456yes|r'); + const used = toolbar.locator('.tt-used-colors'); + await expect(used.locator('.tt-used-sw')).toHaveCount(1); + await expect(used.locator('.tt-used-sw')).toHaveAttribute('data-color', '123456'); +}); + +test('a used-colour swatch re-applies that colour to the selection', async ({ openObjMod }) => { + const { page, host } = await openObjMod(); + const { toolbar } = await openTooltipEditor(page); + await toolbar.locator('.tt-raw-toggle').click(); + const raw = page.locator('textarea.tt-collapsed-raw'); + + await raw.fill('|cff00ff00Green|r plain'); + await expect(toolbar.locator('.tt-used-sw')).toHaveCount(1); + // Select the word "plain" so the swatch has something to wrap. + await raw.evaluate((el) => { el.focus(); el.setSelectionRange(el.value.length - 5, el.value.length); }); + await toolbar.locator('.tt-used-sw').first().click(); + + await expect.poll(() => raw.inputValue()).toContain('|cff00ff00plain|r'); + await expect.poll(() => host.editLabels.length).toBeGreaterThan(0); +}); + +test('a preset swatch wraps the selection and the popover closes', async ({ openObjMod }) => { + const { page } = await openObjMod(); + const { toolbar } = await openTooltipEditor(page); + await toolbar.locator('.tt-raw-toggle').click(); + const raw = page.locator('textarea.tt-collapsed-raw'); + await raw.fill('Colour me'); + await raw.evaluate((el) => { el.focus(); el.setSelectionRange(0, 6); }); + + await toolbar.locator('.tt-color-sq').click(); + const pop = toolbar.locator('.tt-pop'); + await expect(pop).toBeVisible(); + await expect(pop.locator('.tt-palette-label').first()).toHaveText('Presets'); + await expect(pop.locator('.tt-swatches .tt-sw')).not.toHaveCount(0); + + await pop.locator('.tt-sw[data-color="ff0303"]').click(); + await expect(pop).toBeHidden(); + await expect.poll(() => raw.inputValue()).toContain('|cffff0303Colour|r'); +}); + +test('the Saved palette is hidden until a custom colour is picked, then persists to the host', async ({ openObjMod }) => { + const { page, host } = await openObjMod(); + const { toolbar } = await openTooltipEditor(page); + await toolbar.locator('.tt-raw-toggle').click(); + await page.locator('textarea.tt-collapsed-raw').fill('Custom'); + + await toolbar.locator('.tt-color-sq').click(); + const pop = toolbar.locator('.tt-pop'); + await expect(pop.locator('.tt-custom-colors')).toBeHidden(); + + // The native colour input only emits `change` on commit, which Playwright cannot click through + // in the OS picker — set the value and fire the event the browser would. + await pop.locator('input.tt-color').evaluate((el) => { + el.value = '#123456'; + el.dispatchEvent(new Event('change', { bubbles: true })); + }); + + await expect + .poll(() => host.globalState.get(CUSTOM_COLORS_KEY), { message: 'the host should persist the picked colour' }) + .toEqual(['123456']); +}); + +test('saved custom colours come back in the palette after a reload', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod({ + globalState: { [CUSTOM_COLORS_KEY]: ['123456', 'abcdef'] }, + }); + + await gotoHtml(host.html); + const { toolbar } = await openTooltipEditor(page); + await toolbar.locator('.tt-color-sq').click(); + + const custom = toolbar.locator('.tt-pop .tt-custom-colors'); + await expect(custom).toBeVisible(); + await expect(custom.locator('.tt-palette-label')).toHaveText('Saved'); + await expect(custom.locator('.tt-custom-sw')).toHaveCount(2); + await expect(custom.locator('.tt-custom-sw').nth(0)).toHaveAttribute('data-color', '123456'); +}); + +test('a preset colour is never duplicated into the saved palette', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod({ + // ffcc00 is the "Gold" preset; only the non-preset colour should be kept. + globalState: { [CUSTOM_COLORS_KEY]: ['ffcc00', '654321'] }, + }); + await gotoHtml(host.html); + const { toolbar } = await openTooltipEditor(page); + await toolbar.locator('.tt-color-sq').click(); + + const custom = toolbar.locator('.tt-pop .tt-custom-colors'); + await expect(custom.locator('.tt-custom-sw')).toHaveCount(1); + await expect(custom.locator('.tt-custom-sw')).toHaveAttribute('data-color', '654321'); +}); + +test('Escape reverts an in-place edit and leaves the document clean', async ({ openObjMod }) => { + const { page, host } = await openObjMod(); + const { collapsed, body } = await openTooltipEditor(page); + const mi = await collapsed.getAttribute('data-mi'); + const before = (await body.textContent()).trim(); + expect(before).not.toBe(''); + + await body.click(); + await page.keyboard.type('scratch that'); + await page.keyboard.press('Escape'); + + await expect(page.locator('.tt-collapsed-body[contenteditable="true"]')).toHaveCount(0); + await expect(page.locator(`#details .tt-collapsed[data-mi="${mi}"] .tt-collapsed-body`)).toHaveText(before); + expect(host.isDirty).toBe(false); +}); + +test('an edited tooltip is written back as WC3 colour markup on save', async ({ openObjMod }) => { + const { page, host } = await openObjMod(); + const { toolbar } = await openTooltipEditor(page); + await toolbar.locator('.tt-raw-toggle').click(); + await page.locator('textarea.tt-collapsed-raw').fill('|cffffcc00E2E Tooltip|r'); + + await expect.poll(() => host.isDirty, { message: 'editing should dirty the document' }).toBe(true); + await host.save(); + + const written = parseObjMod(host.readFile(), '.w3u'); + const values = written.customObjs + .flatMap((entry) => entry.mods) + .map((mod) => String(mod.value)); + expect(values).toContain('|cffffcc00E2E Tooltip|r'); +}); diff --git a/e2e/specs/w3i-editor.spec.js b/e2e/specs/w3i-editor.spec.js new file mode 100644 index 0000000..3c34cfc --- /dev/null +++ b/e2e/specs/w3i-editor.spec.js @@ -0,0 +1,145 @@ +'use strict'; + +/** + * The editable .w3i map-info editor. + * + * The important contract here is the parse-prefix + opaque-tail model: only the leading string and + * scalar fields are editable, everything after them (players, forces, lists) must come back out of a + * save byte-for-byte. TRIGSTR-backed strings edit war3map.wts; inline strings edit the w3i itself. + */ + +const { test, expect } = require('../fixtures'); +const { repoRequire } = require('../harness/tsLoader'); + +const { parseW3i } = repoRequire('casc-ts/formats'); + +test('renders the map info, resolving TRIGSTR strings through war3map.wts', async ({ openW3i }) => { + const { page, pageErrors } = await openW3i(); + + await expect(page.locator('[data-field="name"]')).toHaveValue('E2E Map Name'); + await expect(page.locator('[data-field="author"]')).toHaveValue('Wurst E2E'); + await expect(page.locator('[data-select="tileset"]')).toHaveValue('L'); + + // The pill is what tells the user this field lives in war3map.wts rather than in the w3i. + const namePill = page.locator('label', { has: page.locator('[data-field="name"]') }).locator('.source-pill'); + await expect(namePill).toHaveText('TRIGSTR_001'); + await expect(page.locator('label', { has: page.locator('[data-field="author"]') }).locator('.source-pill')).toHaveCount(0); + + expect(pageErrors).toEqual([]); +}); + +test('shows the display-only players and forces parsed out of the opaque tail', async ({ openW3i }) => { + const { page } = await openW3i(); + const dialog = page.locator('.dialog'); + await expect(dialog).toContainText('Player 1 (Human)'); + await expect(dialog).toContainText('Player 2 (Computer)'); + await expect(dialog).toContainText('Force 1'); +}); + +test('editing an inline string marks the document dirty and writes it into the w3i bytes', async ({ openW3i }) => { + const { page, host } = await openW3i(); + await expect(page.locator('#dirtyBadge')).toBeHidden(); + + await page.fill('[data-field="author"]', 'Edited Author'); + await page.locator('[data-field="author"]').blur(); + + await expect.poll(() => host.isDirty).toBe(true); + await expect(page.locator('#dirtyBadge')).toBeVisible(); + expect(host.editLabels).toEqual(['Edit author']); + + await host.save(); + expect(host.isDirty).toBe(false); + expect(parseW3i(host.readFile()).author).toBe('Edited Author'); +}); + +test('editing a TRIGSTR-backed string writes war3map.wts, not the w3i bytes', async ({ openW3i }) => { + const { page, host } = await openW3i(); + const before = host.readFile(); + + await page.fill('[data-field="name"]', 'Renamed By E2E'); + await page.locator('[data-field="name"]').blur(); + await expect.poll(() => host.isDirty).toBe(true); + await host.save(); + + // The w3i still holds the TRIGSTR reference; only the string table changed. + const after = parseW3i(host.readFile()); + expect(after.name).toBe('TRIGSTR_001'); + expect(host.readText('war3map.wts')).toContain('Renamed By E2E'); + expect(Buffer.compare(before, host.readFile())).toBe(0); +}); + +test('a save preserves the opaque tail byte-for-byte', async ({ openW3i }) => { + const { page, host } = await openW3i(); + const tailBefore = parseW3i(host.readFile()).tail; + + await page.fill('[data-field="author"]', 'Tail Check'); + await page.locator('[data-field="author"]').blur(); + await expect.poll(() => host.isDirty).toBe(true); + await host.save(); + + const after = parseW3i(host.readFile()); + expect(Buffer.compare(after.tail, tailBefore), 'the opaque tail must round-trip unchanged').toBe(0); + expect(after.players.map((p) => p.name)).toEqual(['Player 1 (Human)', 'Player 2 (Computer)']); + expect(after.forces.map((f) => f.name)).toEqual(['Force 1']); +}); + +test('a flag checkbox toggles the right bit and undo restores it', async ({ openW3i }) => { + const { page, host } = await openW3i(); + const flagsBefore = host.doc.file.flags; + + const checkbox = page.locator('[data-flag]').first(); + const bit = Number(await checkbox.getAttribute('data-flag')); + const wasOn = await checkbox.isChecked(); + await checkbox.setChecked(!wasOn); + + await expect.poll(() => host.doc.file.flags).toBe(wasOn ? flagsBefore & ~bit : flagsBefore | bit); + expect(host.isDirty).toBe(true); + + host.undo(); + expect(host.doc.file.flags).toBe(flagsBefore); + expect(host.isDirty).toBe(false); +}); + +test('changing the tileset select edits the scalar field', async ({ openW3i }) => { + const { page, host } = await openW3i(); + await page.selectOption('[data-select="tileset"]', 'N'); + + await expect.poll(() => host.doc.file.tileset).toBe('N'); + await host.save(); + expect(parseW3i(host.readFile()).tileset).toBe('N'); +}); + +test('re-entering the same value is not treated as an edit', async ({ openW3i }) => { + const { page, host } = await openW3i(); + + await page.fill('[data-field="author"]', 'Wurst E2E'); + await page.locator('[data-field="author"]').blur(); + await page.selectOption('[data-select="tileset"]', 'L'); + + // A real edit right after proves the no-op ones were already delivered and ignored, rather than + // merely still in flight — nothing changed, so the document must still be clean at that point. + await page.fill('[data-field="author"]', 'Definitely Changed'); + await page.locator('[data-field="author"]').blur(); + await expect.poll(() => host.editLabels.length).toBe(1); + host.undo(); + + // Nothing else should have been recorded: otherwise every click-through of the form would leave + // a spurious "unsaved" state and empty undo entries behind it. + expect(host.editLabels).toEqual(['Edit author']); + expect(host.isDirty).toBe(false); + await expect(page.locator('#dirtyBadge')).toBeHidden(); +}); + +test('the custom loading-screen model offers an "Open model" action', async ({ openW3i }) => { + const { page, host } = await openW3i(); + const button = page.locator('[data-open-asset]'); + await expect(button).toHaveAttribute('data-open-asset', 'war3mapImported\\LoadingScreen.mdx'); + + await button.click(); + // The host answers by trying to open the asset; with nothing to resolve it reports back rather + // than failing silently. + await expect + .poll(() => host.vscodeMock.recorded.warnings.length + host.vscodeMock.recorded.info.length + + host.vscodeMock.recorded.errors.length + host.vscodeMock.recorded.commands.length) + .toBeGreaterThan(0); +}); diff --git a/e2e/specs/wpm-editor.spec.js b/e2e/specs/wpm-editor.spec.js new file mode 100644 index 0000000..1ea5ad3 --- /dev/null +++ b/e2e/specs/wpm-editor.spec.js @@ -0,0 +1,199 @@ +'use strict'; + +/** + * The editable .wpm pathing-map editor. + * + * Painting happens on a canvas driven by real pointer events, so the assertions cross-check two + * independent things after each gesture: the bytes the host now holds, and the pixel actually drawn + * under the cursor. Either alone could pass while the editor is visibly broken. + */ + +const { test, expect } = require('../fixtures'); +const { repoRequire } = require('../harness/tsLoader'); + +const { parseWpm } = repoRequire('casc-ts/formats'); + +const NO_WALK = 0x02; +const NO_BUILD = 0x08; + +/** Reads back the on-screen colour at a viewport-relative point, as rgb triplet. */ +function pixelAt(page, x, y) { + return page.evaluate(([px, py]) => { + const canvas = document.getElementById('wpmCanvas'); + const rect = canvas.getBoundingClientRect(); + const ctx = canvas.getContext('2d'); + const data = ctx.getImageData( + Math.round((px - rect.left) * (canvas.width / rect.width)), + Math.round((py - rect.top) * (canvas.height / rect.height)), + 1, 1, + ).data; + return [data[0], data[1], data[2]]; + }, [x, y]); +} + +/** Clicks at the centre of the viewport with the given tool selected. */ +async function paintAtViewportCentre(page, tool) { + await page.click(`[data-tool="${tool}"]`); + const box = await page.locator('#viewport').boundingBox(); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.up(); + return { x, y }; +} + +test('renders the pathing grid with its dimensions and the blocked block from the fixture', async ({ openWpm }) => { + const { page, host, pageErrors } = await openWpm(); + + await expect(page.locator('header .meta')).toHaveText(/16 × 16/); + await expect(page.locator('#wpmCanvas')).toBeVisible(); + expect(host.doc.file.width).toBe(16); + expect(Array.from(host.doc.file.data).filter((v) => v === NO_WALK)).toHaveLength(16); + expect(pageErrors).toEqual([]); +}); + +test('the dirty badge stays hidden until something is painted', async ({ openWpm }) => { + const { page } = await openWpm(); + await expect(page.locator('#dirtyBadge')).toBeHidden(); + + await page.click('[data-tool="paint"]'); + await expect(page.locator('#dirtyBadge')).toBeHidden(); +}); + +test('painting a cell edits the document and repaints that pixel', async ({ openWpm }) => { + const { page, host } = await openWpm(); + await page.click('#btnZoomFit'); + const before = Buffer.from(host.doc.file.data); + const centre = await page.locator('#viewport').boundingBox(); + const blank = await pixelAt(page, centre.x + centre.width / 2, centre.y + centre.height / 2); + + const { x, y } = await paintAtViewportCentre(page, 'paint'); + + await expect.poll(() => host.isDirty, { message: 'painting should dirty the document' }).toBe(true); + await expect(page.locator('#dirtyBadge')).toBeVisible(); + + const changed = []; + for (let i = 0; i < before.length; i++) if (before[i] !== host.doc.file.data[i]) changed.push(i); + expect(changed, 'a single click should edit exactly one cell').toHaveLength(1); + // The default brush is No Walk + No Build (see the checked boxes in buildWpmHtml). + expect(host.doc.file.data[changed[0]]).toBe(NO_WALK | NO_BUILD); + expect(host.editLabels).toEqual(['Paint 1 pathing cell']); + + // No Walk drives the red channel and No Build the blue one (see cellRgb in wpmPreview.ts); the + // green channel is left to whatever the grid/background blend produces, so it isn't asserted. + await expect.poll(() => pixelAt(page, x, y)).not.toEqual(blank); + const [r, , b] = await pixelAt(page, x, y); + expect([r, b]).toEqual([255, 255]); +}); + +test('undo and redo restore the painted cell in both the document and the canvas', async ({ openWpm }) => { + const { page, host } = await openWpm(); + await page.click('#btnZoomFit'); + const before = Buffer.from(host.doc.file.data); + const { x, y } = await paintAtViewportCentre(page, 'paint'); + await expect.poll(() => host.isDirty).toBe(true); + const painted = await pixelAt(page, x, y); + + host.undo(); + await expect.poll(() => host.isDirty).toBe(false); + expect(Buffer.compare(Buffer.from(host.doc.file.data), before)).toBe(0); + await expect.poll(() => pixelAt(page, x, y)).not.toEqual(painted); + await expect(page.locator('#dirtyBadge')).toBeHidden(); + + host.redo(); + await expect.poll(() => host.isDirty).toBe(true); + await expect.poll(() => pixelAt(page, x, y)).toEqual(painted); +}); + +test('a drag paints a line of cells as one undo step', async ({ openWpm }) => { + const { page, host } = await openWpm(); + await page.click('#btnZoomFit'); + await page.click('[data-tool="paint"]'); + const box = await page.locator('#viewport').boundingBox(); + const y = box.y + box.height / 2; + + await page.mouse.move(box.x + box.width * 0.3, y); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.7, y, { steps: 12 }); + await page.mouse.up(); + + await expect.poll(() => host.editLabels.length).toBe(1); + expect(host.editLabels[0]).toMatch(/^Paint \d+ pathing cells$/); + const painted = Number(/Paint (\d+)/.exec(host.editLabels[0])[1]); + expect(painted).toBeGreaterThan(1); + + // One drag is one undo step, however many cells it touched. + host.undo(); + await expect.poll(() => host.isDirty).toBe(false); +}); + +test('the erase tool clears flags instead of painting them', async ({ openWpm }) => { + const { page, host } = await openWpm(); + await page.click('#btnZoomFit'); + const centre = await page.locator('#viewport').boundingBox(); + const blank = await pixelAt(page, centre.x + centre.width / 2, centre.y + centre.height / 2); + + // Paint first so there is definitely something under the cursor to erase. + const before = Buffer.from(host.doc.file.data); + await paintAtViewportCentre(page, 'paint'); + await expect.poll(() => host.isDirty).toBe(true); + const paintedIndex = Array.from(host.doc.file.data).findIndex((v, i) => v !== before[i]); + expect(paintedIndex).toBeGreaterThanOrEqual(0); + + const { x, y } = await paintAtViewportCentre(page, 'erase'); + + await expect.poll(() => host.editLabels.length).toBe(2); + expect(host.editLabels[1]).toBe('Erase 1 pathing cell'); + expect(host.doc.file.data[paintedIndex]).toBe(0); + await expect.poll(() => pixelAt(page, x, y)).toEqual(blank); +}); + +test('Alt+click picks up the brush flags from the cell under the cursor', async ({ openWpm }) => { + const { page } = await openWpm(); + await page.click('#btnZoomFit'); + await paintAtViewportCentre(page, 'paint'); + + await expect(page.locator('#brushValue')).toHaveText('0x0A'); + // Clear the brush, then pick it back up off the cell we just painted. + await page.uncheck('[data-brush-bit="2"]'); + await page.uncheck('[data-brush-bit="8"]'); + await expect(page.locator('#brushValue')).toHaveText('0x00'); + + const box = await page.locator('#viewport').boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.keyboard.down('Alt'); + await page.mouse.down(); + await page.mouse.up(); + await page.keyboard.up('Alt'); + + await expect(page.locator('#brushValue')).toHaveText('0x0A'); + await expect(page.locator('[data-tool="paint"]')).toHaveClass(/active/); +}); + +test('saving writes bytes that re-parse to the painted map', async ({ openWpm }) => { + const { page, host } = await openWpm(); + await page.click('#btnZoomFit'); + await paintAtViewportCentre(page, 'paint'); + await expect.poll(() => host.isDirty).toBe(true); + + await host.save(); + expect(host.isDirty).toBe(false); + + const reparsed = parseWpm(host.readFile()); + expect(reparsed.error).toBeUndefined(); + expect(reparsed.width).toBe(16); + expect(reparsed.height).toBe(16); + expect(Buffer.compare(reparsed.data, Buffer.from(host.doc.file.data))).toBe(0); +}); + +test('the zoom controls change the rendered scale', async ({ openWpm }) => { + const { page } = await openWpm(); + await page.click('#btnZoomFit'); + const fitted = await page.locator('#zoomLabel').textContent(); + + await page.click('#btnZoomIn'); + await expect(page.locator('#zoomLabel')).not.toHaveText(fitted); + await page.click('#btnZoomFit'); + await expect(page.locator('#zoomLabel')).toHaveText(fitted); +}); diff --git a/eslint.config.js b/eslint.config.js index bb62663..95759f2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -59,12 +59,17 @@ module.exports = tseslint.config( }, }, { - // These harnesses embed a function body as a string and run it inside a real browser - // page (Puppeteer/Playwright-style `page.evaluate`) — the browser globals below are - // used there, not in the surrounding Node script. - files: ['scripts/*-e2e.js'], + // The Playwright suite and the harness behind it are Node code that also contains callbacks + // evaluated inside a real browser page (`page.evaluate`, `locator.evaluate`), so both global + // sets are legitimately in scope in the same file. + files: ['e2e/**/*.js'], languageOptions: { globals: { ...globals.node, ...globals.browser }, }, + rules: { + // Playwright's own web-first assertions (`await expect(locator).toBeVisible()`) aren't + // recognised by this rule, so it fires on tests that are entirely assertions. + 'sonarjs/assertions-in-tests': 'off', + }, }, ); diff --git a/package-lock.json b/package-lock.json index 830831e..ba4dcfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", + "@playwright/test": "^1.62.1", "@types/node": "^22.19.17", "@types/vscode": "^1.109.0", "@types/webpack-env": "^1.18.8", @@ -34,17 +35,25 @@ } }, "../casc-ts": { - "version": "0.0.1", - "dependencies": { + "version": "1.0.0", + "license": "MIT", + "devDependencies": { "@types/node": "^20.19.39", "typescript": "^5.4.0", "vitest": "^2.1.0" + }, + "engines": { + "node": ">=18" } }, "../war3-model": { - "version": "0.0.1", + "version": "4.0.1", "dev": true, + "license": "MIT", "dependencies": { + "gl-matrix": "3.3.0" + }, + "devDependencies": { "@eslint/js": "10.0.1", "@types/eslint": "9.6.1", "@types/estree": "1.0.8", @@ -52,13 +61,15 @@ "@webgpu/types": "0.1.69", "dds-parser": "1.0.1", "eslint": "10.0.3", - "gl-matrix": "3.3.0", "husky": "7.0.4", "monaco-editor": "0.55.1", "typescript": "5.9.3", "typescript-eslint": "8.57.0", "vite": "8.0.0", "vite-plugin-dts": "4.5.4" + }, + "engines": { + "node": ">=4.0.0" } }, "node_modules/@azu/format-text": { @@ -677,6 +688,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@secretlint/config-creator": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", @@ -3286,6 +3313,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4808,6 +4850,38 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -7083,6 +7157,15 @@ "fastq": "^1.6.0" } }, + "@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "requires": { + "playwright": "1.62.1" + } + }, "@secretlint/config-creator": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", @@ -8888,6 +8971,13 @@ "universalify": "^2.0.0" } }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9933,6 +10023,22 @@ "find-up": "^4.0.0" } }, + "playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.62.1" + } + }, + "playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true + }, "pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 7e2abe2..71b8499 100644 --- a/package.json +++ b/package.json @@ -884,15 +884,17 @@ "compile-web": "webpack", "watch-web": "webpack --watch", "test": "npm run test:fuzzy && npm run test:image-decoders && npm run test:diagnostics && npm run test:webview", + "test:e2e": "npm run compile-web && playwright test --project=webview", + "test:e2e:ui": "playwright test --project=webview --ui", "test:image-decoders": "node ./scripts/test-image-decoders.js", "test:diagnostics": "node ./scripts/test-diagnostics.js", "test:vsix-contents": "node ./scripts/test-vsix-contents.js", "sync:wc3-knowledge-base": "node ./scripts/sync-wc3-knowledge-base.js", "test:webview": "node ./scripts/test-webview.js", - "test:e2e:models:local": "node ./scripts/model-thumbnail-e2e.js", - "test:e2e:objmod-thumbs:local": "node ./scripts/objmod-thumbnail-e2e.js", - "test:e2e:asset-browser-code:local": "node ./scripts/objmod-thumbnail-e2e.js --code-only", - "test:e2e:objmod-clipboard:local": "node ./scripts/objmod-clipboard-e2e.js", + "test:e2e:local": "playwright test --project=local-vscode", + "test:e2e:models:local": "playwright test --project=local-vscode model-thumbnails", + "test:e2e:objmod-thumbs:local": "playwright test --project=local-vscode objmod-thumbnails", + "test:e2e:objmod-clipboard:local": "playwright test --project=local-vscode objmod-clipboard", "test:wc3-previews": "node ./scripts/wc3-preview-smoke.js", "test:fuzzy": "node ./scripts/test-fuzzy.js", "package-web": "webpack --mode production --devtool hidden-source-map", @@ -900,6 +902,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", + "@playwright/test": "^1.62.1", "@types/node": "^22.19.17", "@types/vscode": "^1.109.0", "@types/webpack-env": "^1.18.8", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..72a40d5 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,44 @@ +'use strict'; + +const { defineConfig, devices } = require('@playwright/test'); + +/** + * Browser-level e2e for the extension's webviews. + * + * These run the *real* host code (via e2e/harness) and the *real* webview bundles in Chromium, with + * VS Code itself faked. No Warcraft III install and no VS Code launch is required, so unlike the + * `test:e2e:*:local` scripts these are safe to run in CI. + */ +module.exports = defineConfig({ + // Every spec drives a UI whose failures are almost always deterministic; a retry mostly hides + // a real race, so surface it instead. + retries: 0, + fullyParallel: true, + forbidOnly: !!process.env.CI, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? [['github'], ['list']] : [['list']], + timeout: 30_000, + expect: { timeout: 7_000 }, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1280, height: 800 }, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'webview', + testDir: './e2e/specs', + }, + { + // Real VS Code / real Warcraft III data. Opt-in via WURST_OBJMOD_E2E=1 or + // WURST_MODEL_E2E=1 (the specs skip themselves otherwise), never run in CI, and serial + // because each test owns an OS window and, for the clipboard specs, the OS clipboard. + name: 'local-vscode', + testDir: './e2e/local', + fullyParallel: false, + workers: 1, + timeout: 180_000, + }, + ], +}); diff --git a/scripts/model-thumbnail-e2e.js b/scripts/model-thumbnail-e2e.js deleted file mode 100644 index 10971f6..0000000 --- a/scripts/model-thumbnail-e2e.js +++ /dev/null @@ -1,554 +0,0 @@ -'use strict'; - -/** - * Local-only thumbnail benchmark/e2e harness. - * - * This launches a real Chromium/Edge instance, loads dist/webview/mdxViewer.js, - * reuses one warm War3Viewer instance, renders deterministic still thumbnails, - * and checks blankness, darkness, optional snapshots, and logs per-thumbnail latency. - * - * Enable explicitly: - * $env:WURST_MODEL_E2E='1'; npm run test:e2e:models:local - * - * Useful knobs: - * WURST_MODEL_BENCH_MODELS semicolon-separated .mdx/.mdl paths - * WURST_MODEL_TEXTURE_ROOTS semicolon-separated texture roots - * WURST_MODEL_SNAPSHOT_FILE default %TEMP%/wurst-model-thumbnail.snapshots.json - * WURST_MODEL_UPDATE_SNAPSHOTS set to 1 to write/update snapshots - * CHROME_PATH explicit browser executable - */ - -const assert = require('assert'); -const childProcess = require('child_process'); -const fs = require('fs'); -const http = require('http'); -const os = require('os'); -const path = require('path'); - -const root = path.resolve(__dirname, '..'); -const enabled = process.env.WURST_MODEL_E2E === '1' || process.env.WURST_LOCAL_E2E === '1'; - -if (!enabled) { - console.log('local model thumbnail e2e skipped (set WURST_MODEL_E2E=1 to enable)'); - process.exit(0); -} - -if (typeof WebSocket !== 'function') { - throw new Error('This harness needs Node with global WebSocket support (Node 22+ here is fine).'); -} - -const perModelTimeoutMs = Number(process.env.WURST_MODEL_E2E_MODEL_TIMEOUT_MS || 15000); -const browserEvalTimeoutMs = Number(process.env.WURST_MODEL_E2E_BROWSER_TIMEOUT_MS || Math.max(30000, perModelTimeoutMs * 2)); -const snapshotFile = process.env.WURST_MODEL_SNAPSHOT_FILE || - path.join(os.tmpdir(), 'wurst-model-thumbnail.snapshots.json'); -const updateSnapshots = process.env.WURST_MODEL_UPDATE_SNAPSHOTS === '1'; - -function benchLog(message) { - console.log(`[model-e2e] ${message}`); -} - -function splitEnvPaths(value) { - return String(value || '').split(';').map((part) => part.trim()).filter(Boolean); -} - -function defaultModelPaths() { - const base = path.join(root, 'node_modules', 'war3-model'); - return [ - // Self-contained custom fixture: model + main custom BLP live together. - // Broader stock-game fixtures can be supplied via WURST_MODEL_BENCH_MODELS - // once WURST_MODEL_TEXTURE_ROOTS points at extracted/CASC-cached game data. - 'BrutalLord.MDX', - ].map((name) => path.join(base, name)).filter((file) => fs.existsSync(file)); -} - -function normalizeAsset(value) { - return String(value || '').replace(/\0/g, '').replace(/\//g, '\\').toLowerCase(); -} - -function browserCandidates() { - const env = process.env.CHROME_PATH ? [process.env.CHROME_PATH] : []; - if (process.platform === 'win32') { - const local = process.env.LOCALAPPDATA || ''; - const programFiles = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)']].filter(Boolean); - return [ - ...env, - path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'), - path.join(local, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), - ...programFiles.flatMap((dir) => [ - path.join(dir, 'Google', 'Chrome', 'Application', 'chrome.exe'), - path.join(dir, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), - ]), - ]; - } - if (process.platform === 'darwin') { - return [ - ...env, - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - ]; - } - return [...env, 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge']; -} - -function findBrowser() { - for (const candidate of browserCandidates()) { - if (!candidate) continue; - if (candidate.includes(path.sep) && !fs.existsSync(candidate)) continue; - return candidate; - } - throw new Error('No Chromium/Edge executable found. Set CHROME_PATH.'); -} - -function readFileBase64(filePath) { - return fs.readFileSync(filePath).toString('base64'); -} - -function addTexture(textureMap, key, payload) { - if (!key) return; - const normalized = normalizeAsset(key); - if (!textureMap[normalized]) textureMap[normalized] = payload; - const base = path.basename(normalized); - if (base && !textureMap[base]) textureMap[base] = payload; -} - -function walkTextures(rootDir, textureMap, budget, depth = 0) { - if (budget.count <= 0 || budget.dirs <= 0 || Date.now() > budget.deadline || depth > budget.maxDepth) return; - budget.dirs--; - let entries; - try { entries = fs.readdirSync(rootDir, { withFileTypes: true }); } catch { return; } - for (const entry of entries) { - if (budget.count <= 0 || budget.dirs <= 0 || Date.now() > budget.deadline) return; - const full = path.join(rootDir, entry.name); - if (entry.isDirectory()) { - if (entry.name.startsWith('.') || ['node_modules', 'dist', 'out', '.git'].includes(entry.name.toLowerCase())) continue; - walkTextures(full, textureMap, budget, depth + 1); - continue; - } - const ext = path.extname(entry.name).toLowerCase(); - if (!['.blp', '.dds', '.tga'].includes(ext)) continue; - budget.count--; - const payload = { kind: ext.slice(1), base64: readFileBase64(full) }; - addTexture(textureMap, entry.name, payload); - addTexture(textureMap, path.relative(rootDir, full), payload); - addTexture(textureMap, full, payload); - } -} - -function buildFixtures(modelPaths) { - const textureMap = {}; - const textureRoots = new Set([ - path.join(root, 'node_modules', 'war3-model'), - ...modelPaths.map((file) => path.dirname(file)), - ...splitEnvPaths(process.env.WURST_MODEL_TEXTURE_ROOTS), - ]); - const budget = { - count: Number(process.env.WURST_MODEL_TEXTURE_BUDGET || 4000), - dirs: Number(process.env.WURST_MODEL_TEXTURE_DIR_BUDGET || 1500), - maxDepth: Number(process.env.WURST_MODEL_TEXTURE_MAX_DEPTH || 8), - deadline: Date.now() + Number(process.env.WURST_MODEL_TEXTURE_SCAN_MS || 5000), - }; - for (const textureRoot of textureRoots) walkTextures(textureRoot, textureMap, budget); - - return modelPaths.map((file) => ({ - name: path.basename(file), - fileName: path.basename(file), - format: path.extname(file).toLowerCase() === '.mdl' ? 'mdl' : 'mdx', - modelBase64: readFileBase64(file), - textures: textureMap, - })); -} - -function startStaticServer() { - const server = http.createServer((req, res) => { - const rawUrl = new URL(req.url || '/', 'http://127.0.0.1'); - if (rawUrl.pathname === '/') { - res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); - res.end(` -model thumb e2e - - -
- -`); - return; - } - const filePath = path.resolve(root, rawUrl.pathname.replace(/^\/+/, '').replace(/\//g, path.sep)); - if (!filePath.startsWith(root) || !fs.existsSync(filePath)) { - res.writeHead(404); - res.end('not found'); - return; - } - res.writeHead(200, { 'content-type': filePath.endsWith('.js') ? 'text/javascript' : 'application/octet-stream' }); - fs.createReadStream(filePath).pipe(res); - }); - return new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve(server)); - }); -} - -function requestJson(url) { - return new Promise((resolve, reject) => { - http.get(url, (res) => { - let body = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { body += chunk; }); - res.on('end', () => { - try { resolve(JSON.parse(body)); } catch (err) { reject(err); } - }); - }).on('error', reject); - }); -} - -async function waitForDevtoolsPort(userDataDir) { - const file = path.join(userDataDir, 'DevToolsActivePort'); - const deadline = Date.now() + 10000; - while (Date.now() < deadline) { - try { - const [port] = fs.readFileSync(file, 'utf8').split(/\r?\n/); - if (port) return Number(port); - } catch { - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } - throw new Error('Timed out waiting for browser DevToolsActivePort.'); -} - -class CdpClient { - constructor(wsUrl) { - this.wsUrl = wsUrl; - this.nextId = 1; - this.pending = new Map(); - } - - async connect() { - this.ws = new WebSocket(this.wsUrl); - this.ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (!msg.id) return; - const pending = this.pending.get(msg.id); - if (!pending) return; - this.pending.delete(msg.id); - if (msg.error) pending.reject(new Error(msg.error.message || JSON.stringify(msg.error))); - else pending.resolve(msg.result); - }; - await new Promise((resolve, reject) => { - this.ws.onopen = resolve; - this.ws.onerror = reject; - }); - } - - send(method, params = {}) { - const id = this.nextId++; - this.ws.send(JSON.stringify({ id, method, params })); - return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })); - } - - close() { - try { this.ws.close(); } catch {} - } -} - -async function waitForExpression(client, expression, timeoutMs = 10000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const result = await client.send('Runtime.evaluate', { - expression, - returnByValue: true, - }); - if (result.result?.value) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(`Timed out waiting for ${expression}`); -} - -function browserBenchExpression(fixtures) { - return `(${async function runModelBench(fixturesArg, timeoutMsArg) { - const b64ToArrayBuffer = (b64) => { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out.buffer; - }; - const normalize = (value) => String(value || '').replace(/\0/g, '').replace(/\//g, '\\').toLowerCase(); - // eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): pre-existing page.evaluate() browser-context callback logic; tracked for a decomposition pass rather than a rushed change to this e2e harness. - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); - const metricsForImage = (imageData) => { - const px = imageData.data; - let alphaPixels = 0; - let visiblePixels = 0; - let lumaSum = 0; - let maxLuma = 0; - let h1 = 0x811c9dc5; - let h2 = 0x27d4eb2d; - for (let i = 0; i < px.length; i += 4) { - const a = px[i + 3]; - const rgbVisible = px[i] + px[i + 1] + px[i + 2] > 24; - if (a > 12) alphaPixels++; - if (a > 12 || rgbVisible) { - const luma = px[i] * 0.2126 + px[i + 1] * 0.7152 + px[i + 2] * 0.0722; - visiblePixels++; - lumaSum += luma; - if (luma > maxLuma) maxLuma = luma; - } - h1 = Math.imul(h1 ^ px[i], 0x01000193) >>> 0; - h1 = Math.imul(h1 ^ px[i + 1], 0x01000193) >>> 0; - h1 = Math.imul(h1 ^ px[i + 2], 0x01000193) >>> 0; - h2 = Math.imul(h2 ^ a, 0x85ebca6b) >>> 0; - } - return { - alphaPixels, - visiblePixels, - avgLuma: visiblePixels ? lumaSum / visiblePixels : 0, - maxLuma, - hash: imageData.width + 'x' + imageData.height + '-' + h1.toString(16).padStart(8, '0') + '-' + h2.toString(16).padStart(8, '0'), - }; - }; - - const canvas = document.getElementById('canvas'); - const gizmo = document.getElementById('gizmo'); - const viewport = document.getElementById('viewport'); - canvas.width = 96; - canvas.height = 96; - const messages = []; - let loadedResolve = null; - let errors = []; - window.War3Viewer.init({ - canvas3d: canvas, - gizmo, - viewport, - vscodeApi: { postMessage(message) { messages.push(message); } }, - callbacks: { - onModelLoaded(info) { if (loadedResolve) loadedResolve(info); }, - onFrameUpdate() {}, - onDebug() {}, - onError(message) { errors.push(message); }, - }, - }); - - async function withTimeout(label, promise, timeoutMs) { - let timer = 0; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - // eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): pre-existing page.evaluate() browser-context callback logic; tracked for a decomposition pass rather than a rushed change to this e2e harness. - timer = window.setTimeout(() => reject(new Error(label + ' timed out after ' + timeoutMs + 'ms')), timeoutMs); - }), - ]); - } finally { - if (timer) window.clearTimeout(timer); - } - } - - async function runOne(fixture, warmup) { - messages.length = 0; - errors = []; - const t0 = performance.now(); - let loadedReject = null; - let waitingForLoad = true; - const loaded = new Promise((resolve, reject) => { - loadedResolve = resolve; - loadedReject = reject; - }); - try { - window.War3Viewer.loadModel(b64ToArrayBuffer(fixture.modelBase64), fixture.fileName, fixture.format, { autoplay: false }); - } catch (err) { - loadedReject(err); - } - let info; - try { - info = await withTimeout( - fixture.name + ' loadModel', - new Promise((resolve, reject) => { - loaded.then(resolve, reject); - // eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): pre-existing page.evaluate() browser-context callback logic; tracked for a decomposition pass rather than a rushed change to this e2e harness. - const check = () => { - if (!waitingForLoad) return; - if (errors.length) reject(new Error(fixture.name + ': ' + errors.join('; '))); - else requestAnimationFrame(check); - }; - requestAnimationFrame(check); - }), - timeoutMsArg - ); - } finally { - waitingForLoad = false; - loadedResolve = null; - } - const tLoaded = performance.now(); - if (errors.length) throw new Error(fixture.name + ': ' + errors.join('; ')); - - const requests = messages.filter((message) => message && message.type === 'requestTextures'); - const loadedTextures = []; - const missingTextures = []; - for (const request of requests) { - for (const texPath of request.paths || []) { - const key = normalize(texPath); - const base = key.split('\\').pop(); - const payload = fixture.textures[key] || fixture.textures[base]; - if (!payload) { - missingTextures.push(texPath); - window.War3Viewer.onTexture(texPath, null); - } else if (payload.kind === 'dds') { - loadedTextures.push(texPath); - window.War3Viewer.onTextureDds(texPath, b64ToArrayBuffer(payload.base64)); - } else { - loadedTextures.push(texPath); - window.War3Viewer.onTexture(texPath, b64ToArrayBuffer(payload.base64)); - } - } - } - const tTextures = performance.now(); - - const standIndex = Math.max(0, info.sequences.findIndex((seq) => /stand/i.test(seq.name || ''))); - if (info.sequences.length) { - const seq = info.sequences[standIndex] || info.sequences[0]; - window.War3Viewer.setSequence(standIndex); - window.War3Viewer.setFrame(Math.round(seq.start + Math.max(0, seq.end - seq.start) * 0.2)); - } - window.War3Viewer.resetCamera(); - window.War3Viewer.zoomOut(); - window.War3Viewer.zoomOut(); - window.War3Viewer.setAutoplay(false); - await nextFrame(); - await nextFrame(); - window.War3Viewer.renderStillFrame(); - const imageData = window.War3Viewer.readPixelsImageData(); - const metrics = metricsForImage(imageData); - const tDone = performance.now(); - return { - name: fixture.name, - warmup, - geosets: info.geosetCount, - textures: info.textureCount, - requestedTextures: requests.reduce((sum, request) => sum + ((request.paths || []).length), 0), - loadedTextures: loadedTextures.length, - missingTextures, - loadMs: tLoaded - t0, - textureMs: tTextures - tLoaded, - renderMs: tDone - tTextures, - totalMs: tDone - t0, - alphaPixels: metrics.alphaPixels, - visiblePixels: metrics.visiblePixels, - avgLuma: metrics.avgLuma, - maxLuma: metrics.maxLuma, - snapshotHash: metrics.hash, - webpBytes: Math.round((canvas.toDataURL('image/webp', 0.58).length - 'data:image/webp;base64,'.length) * 0.75), - }; - } - - const results = []; - if (fixturesArg.length) { - results.push(await runOne(fixturesArg[0], true)); - } - for (const fixture of fixturesArg) { - results.push(await runOne(fixture, false)); - } - return results; - }})(${JSON.stringify(fixtures)}, ${JSON.stringify(perModelTimeoutMs)})`; -} - -function readSnapshots() { - try { return JSON.parse(fs.readFileSync(snapshotFile, 'utf8')); } catch { return {}; } -} - -function writeSnapshots(results) { - const snapshots = {}; - for (const result of results.filter((entry) => !entry.warmup)) { - snapshots[result.name] = { - snapshotHash: result.snapshotHash, - alphaPixels: result.alphaPixels, - visiblePixels: result.visiblePixels, - avgLuma: Number(result.avgLuma.toFixed(2)), - maxLuma: Number(result.maxLuma.toFixed(2)), - }; - } - fs.mkdirSync(path.dirname(snapshotFile), { recursive: true }); - fs.writeFileSync(snapshotFile, JSON.stringify(snapshots, null, 2) + '\n'); -} - -// eslint-disable-next-line sonarjs/cognitive-complexity -- TODO(lint-cleanup): pre-existing, tracked for a dedicated decomposition pass rather than a rushed refactor here. -async function main() { - const modelPaths = splitEnvPaths(process.env.WURST_MODEL_BENCH_MODELS); - const models = (modelPaths.length ? modelPaths : defaultModelPaths()).map((file) => path.resolve(file)); - assert.ok(models.length, 'No model fixtures found. Set WURST_MODEL_BENCH_MODELS.'); - assert.ok(fs.existsSync(path.join(root, 'dist', 'webview', 'mdxViewer.js')), 'Missing dist/webview/mdxViewer.js. Run npm run package-web first.'); - - benchLog(`fixtures=${models.length} textureScanMs=${process.env.WURST_MODEL_TEXTURE_SCAN_MS || 5000}`); - const fixtures = buildFixtures(models); - benchLog(`textures indexed=${Object.keys(fixtures[0]?.textures || {}).length}`); - const server = await startStaticServer(); - const serverPort = server.address().port; - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-model-e2e-')); - benchLog(`browser=${findBrowser()}`); - const browser = childProcess.spawn(findBrowser(), [ - '--headless=new', - '--no-first-run', - '--no-default-browser-check', - '--disable-background-networking', - '--enable-webgl', - '--remote-debugging-port=0', - `--user-data-dir=${userDataDir}`, - `http://127.0.0.1:${serverPort}/`, - ], { stdio: ['ignore', 'ignore', 'pipe'] }); - - let client; - try { - const devtoolsPort = await waitForDevtoolsPort(userDataDir); - const pages = await requestJson(`http://127.0.0.1:${devtoolsPort}/json/list`); - const page = pages.find((entry) => entry.type === 'page') || pages[0]; - if (!page?.webSocketDebuggerUrl) throw new Error('No page websocket from browser.'); - client = new CdpClient(page.webSocketDebuggerUrl); - await client.connect(); - await client.send('Runtime.enable'); - await waitForExpression(client, '!!window.War3Viewer'); - benchLog(`browser bench start timeout=${browserEvalTimeoutMs}ms`); - const evalResult = await client.send('Runtime.evaluate', { - expression: browserBenchExpression(fixtures), - awaitPromise: true, - returnByValue: true, - timeout: browserEvalTimeoutMs, - }); - if (evalResult.exceptionDetails) { - throw new Error(evalResult.exceptionDetails.text || 'browser benchmark failed'); - } - const results = evalResult.result.value; - if (updateSnapshots) writeSnapshots(results); - const snapshots = readSnapshots(); - const failures = []; - for (const result of results.filter((entry) => !entry.warmup)) { - if (result.visiblePixels < 24) failures.push(`${result.name}: blank render (${result.visiblePixels} visible pixels, ${result.alphaPixels} alpha pixels)`); - if (result.avgLuma < 3 && result.maxLuma < 16) failures.push(`${result.name}: too dark avg=${result.avgLuma.toFixed(1)} max=${result.maxLuma.toFixed(1)}`); - const snapshot = snapshots[result.name]; - if (snapshot && snapshot.snapshotHash !== result.snapshotHash) { - failures.push(`${result.name}: snapshot changed ${snapshot.snapshotHash} -> ${result.snapshotHash}`); - } - } - for (const result of results) { - const tag = result.warmup ? 'warmup' : 'bench '; - const missingSuffix = result.missingTextures.length ? ' missing=' + result.missingTextures.join(',') : ''; - console.log(`${tag} ${result.name}: total=${result.totalMs.toFixed(1)}ms load=${result.loadMs.toFixed(1)}ms textures=${result.textureMs.toFixed(1)}ms render=${result.renderMs.toFixed(1)}ms visible=${result.visiblePixels} alpha=${result.alphaPixels} geosets=${result.geosets} tex=${result.loadedTextures}/${result.requestedTextures} hash=${result.snapshotHash}${missingSuffix}`); - } - if (!Object.keys(snapshots).length && !updateSnapshots) { - console.log(`no snapshot baseline at ${snapshotFile}; set WURST_MODEL_UPDATE_SNAPSHOTS=1 to create one`); - } - assert.equal(failures.length, 0, failures.join('\n')); - const maxObserved = Math.max(...results.filter((entry) => !entry.warmup).map((entry) => entry.totalMs)); - console.log(`local model thumbnail e2e passed (${results.length - 1} benchmark fixture${results.length === 2 ? '' : 's'}, max observed ${maxObserved.toFixed(1)}ms)`); - } finally { - client?.close(); - browser.kill(); - server.close(); - } -} - -main().catch((err) => { - console.error(err); - process.exitCode = 1; -}); diff --git a/scripts/objmod-clipboard-e2e.js b/scripts/objmod-clipboard-e2e.js deleted file mode 100644 index d7f597d..0000000 --- a/scripts/objmod-clipboard-e2e.js +++ /dev/null @@ -1,719 +0,0 @@ -'use strict'; - -/** - * Local-only VS Code extension e2e for the objmod tooltip editor's copy/cut/paste. - * - * Enable explicitly, never in CI: - * $env:WURST_OBJMOD_E2E='1' - * npm run test:e2e:objmod-clipboard:local - * - * Unlike the other e2e scripts here, this one dispatches *real* OS-trusted keystrokes via CDP's - * Input.dispatchKeyEvent — document.execCommand('copy'/'cut') silently no-ops for a script-synthesized - * event with no user gesture behind it, so calling debug-hook functions alone (as the thumbnail e2e - * does) can't actually prove copy/cut/paste works; it can only prove the DOM/selection state is right. - * - * Optional knobs: - * WURST_OBJMOD_E2E_CODE Code.exe path, if it cannot be found - * WURST_OBJMOD_E2E_TIMEOUT_MS total wait timeout, default 45000 - */ - -const assert = require('assert'); -const childProcess = require('child_process'); -const fs = require('fs'); -const http = require('http'); -const net = require('net'); -const os = require('os'); -const path = require('path'); - -const root = path.resolve(__dirname, '..'); -const enabled = process.env.WURST_OBJMOD_E2E === '1' || process.env.WURST_LOCAL_E2E === '1'; - -if (!enabled) { - console.log('local objmod clipboard e2e skipped (set WURST_OBJMOD_E2E=1 to enable)'); - process.exit(0); -} - -if (typeof WebSocket !== 'function') { - throw new Error('This harness needs Node with global WebSocket support (Node 22+).'); -} - -const timeoutMs = Number(process.env.WURST_OBJMOD_E2E_TIMEOUT_MS || 45000); - -function log(message) { - console.log(`[objmod-clipboard-e2e] ${message}`); -} - -// A color-coded value (not just a "tooltip"-labelled field) is what actually routes this field -// through the rich tooltip editor (see needsColorEditor in fieldDisplay.ts) — using the Name field -// keeps this fixture minimal instead of having to know a real ability's tooltip field id. -const CLIPBOARD_TEST_VALUE = '|cffffcc00Copy Paste Test|r'; - -function writeGeneratedObjmodFixture() { - const { serializeObjMod } = require('casc-ts/formats'); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-objmod-clip-fixture-')); - fs.writeFileSync(path.join(dir, 'wurst.build'), 'projectName = objmod-clipboard-e2e\n'); - const main = { - version: 3, - ext: '.w3a', - extended: true, - origObjs: [{ - baseId: 'Ahrp', - newId: null, - mods: [ - { fieldId: 'anam', varType: 'string', level: 0, dataPt: 0, value: 'Repair E2E Override', endToken: '\0\0\0\0' }, - ], - }], - customObjs: [{ - baseId: 'Ahrp', - newId: 'Z001', - mods: [ - { fieldId: 'anam', varType: 'string', level: 0, dataPt: 0, value: CLIPBOARD_TEST_VALUE, endToken: '\0\0\0\0' }, - ], - }], - }; - const skin = { - version: 3, - ext: '.w3a', - extended: true, - origObjs: [{ - baseId: 'Ahrp', - newId: null, - mods: [ - { fieldId: 'aart', varType: 'string', level: 0, dataPt: 0, value: 'ReplaceableTextures\\CommandButtons\\BTNRepair.blp', endToken: '\0\0\0\0' }, - ], - }], - customObjs: [], - }; - fs.writeFileSync(path.join(dir, 'war3map.w3a'), serializeObjMod(main)); - fs.writeFileSync(path.join(dir, 'war3mapSkin.w3a'), serializeObjMod(skin)); - return { dir, file: path.join(dir, 'war3map.w3a') }; -} - -const generated = writeGeneratedObjmodFixture(); -const projectPath = generated.dir; -const objmodFile = generated.file; - -function codeCandidates() { - const explicit = process.env.WURST_OBJMOD_E2E_CODE; - const candidates = explicit ? [explicit] : []; - if (process.platform === 'win32') { - const local = process.env.LOCALAPPDATA || ''; - const programFiles = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)']].filter(Boolean); - candidates.push( - path.join(local, 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'), - path.join(local, 'Programs', 'Microsoft VS Code', 'Code.exe'), - ...programFiles.map((dir) => path.join(dir, 'Microsoft VS Code', 'bin', 'code.cmd')), - ...programFiles.map((dir) => path.join(dir, 'Microsoft VS Code', 'Code.exe')), - ); - } else if (process.platform === 'darwin') { - candidates.push('/Applications/Visual Studio Code.app/Contents/MacOS/Electron'); - } else { - candidates.push('code', 'code-insiders'); - } - if (!explicit && process.env.VSCODE_PATH) candidates.push(process.env.VSCODE_PATH); - return candidates; -} - -function findCode() { - if (process.platform === 'win32' && !process.env.WURST_OBJMOD_E2E_CODE) { - const localShim = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'); - const localExe = path.resolve(path.dirname(localShim), '..', 'Code.exe'); - if (fs.existsSync(localExe)) return localExe; - } - for (const candidate of codeCandidates()) { - if (!candidate) continue; - const clean = String(candidate).replace(/^['"]|['"]$/g, ''); - const normalized = /(?:^|[\\/])code\.cmd$/i.test(clean) - ? path.resolve(path.dirname(clean), '..', 'Code.exe') - : clean; - if (normalized.includes(path.sep) && !fs.existsSync(normalized)) continue; - return normalized; - } - throw new Error('Could not find VS Code. Set WURST_OBJMOD_E2E_CODE.'); -} - -function spawnCode(code, args, childEnv) { - if (process.platform === 'win32' && /\.cmd$/i.test(code)) { - return childProcess.spawn('cmd.exe', ['/d', '/c', code, ...args], { - env: childEnv, - stdio: ['ignore', 'ignore', 'pipe'], - }); - } - return childProcess.spawn(code, args, { - env: childEnv, - detached: process.platform !== 'win32', - stdio: ['ignore', 'ignore', 'pipe'], - }); -} - -function waitForExit(child, waitMs = 5000) { - if (child.exitCode != null || child.signalCode != null) return Promise.resolve(); - return new Promise((resolve) => { - const timer = setTimeout(resolve, waitMs); - child.once('exit', () => { - clearTimeout(timer); - resolve(); - }); - }); -} - -// CDP's Page.bringToFront only reorders Chromium's own internal compositing/target activation; it does -// not call the Win32 SetForegroundWindow the OS actually uses to decide which window receives real -// keyboard input. Without this, VS Code's keybinding service silently ignores every CDP-dispatched -// keystroke (Ctrl+Shift+P, Ctrl+C, ...) because the BrowserWindow never became the real foreground -// window — whatever window the calling terminal/IDE last had focus on stays in front. -function bringVsCodeWindowToForeground(userDataDir) { - if (process.platform !== 'win32') return; - const result = childProcess.spawnSync('powershell.exe', [ - '-NoProfile', - '-File', path.join(__dirname, 'bring-to-foreground.ps1'), - '-Needle', userDataDir, - ], { encoding: 'utf8', windowsHide: true, timeout: 10000 }); - if (process.env.WURST_E2E_VERBOSE === '1') { - const stderrSuffix = result.stderr ? ' stderr=' + result.stderr.trim() : ''; - console.log(`[verbose] bringVsCodeWindowToForeground: ${(result.stdout || '').trim() || '(no matching window)'}${stderrSuffix}`); - } -} - -function windowsCodePidsForUserDataDir(userDataDir) { - if (process.platform !== 'win32' || !userDataDir) return []; - const result = childProcess.spawnSync('powershell.exe', [ - '-NoProfile', - '-Command', - '$needle=$env:WURST_E2E_USER_DATA_DIR; ' + - 'Get-CimInstance Win32_Process -Filter "name = \'Code.exe\'" | ' + - 'Where-Object { $_.CommandLine -like "*$needle*" } | ' + - 'ForEach-Object { $_.ProcessId }', - ], { - env: { ...process.env, WURST_E2E_USER_DATA_DIR: userDataDir }, - encoding: 'utf8', - windowsHide: true, - timeout: 10000, - }); - if (result.status !== 0) return []; - return result.stdout - .split(/\r?\n/) - .map((line) => Number(line.trim())) - .filter((pid) => Number.isInteger(pid) && pid > 0); -} - -async function waitForNoWindowsCodeProfile(userDataDir, waitMs = 5000) { - if (process.platform !== 'win32') return; - const deadline = Date.now() + waitMs; - while (Date.now() < deadline) { - if (!windowsCodePidsForUserDataDir(userDataDir).length) return; - await new Promise((resolve) => setTimeout(resolve, 100)); - } -} - -// eslint-disable-next-line sonarjs/cognitive-complexity -- TODO(lint-cleanup): pre-existing, tracked for a dedicated decomposition pass rather than a rushed refactor here. -async function killProcessTree(child, userDataDir) { - if (process.platform === 'win32') { - const pids = new Set(windowsCodePidsForUserDataDir(userDataDir)); - if (child?.pid && child.exitCode == null && child.signalCode == null) pids.add(child.pid); - for (const pid of pids) { - childProcess.spawnSync('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true, - timeout: 10000, - }); - } - await waitForNoWindowsCodeProfile(userDataDir); - } else { - if (!child?.pid || child.exitCode != null || child.signalCode != null) return; - try { process.kill(-child.pid, 'SIGTERM'); } catch {} - await waitForExit(child, 2000); - if (child.exitCode == null && child.signalCode == null) { - try { process.kill(-child.pid, 'SIGKILL'); } catch {} - } - } - await waitForExit(child, 5000); -} - -function cleanupTempDir(dir) { - if (!dir || !path.resolve(dir).startsWith(os.tmpdir())) return; - try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch {} -} - -function requestJson(url) { - return new Promise((resolve, reject) => { - http.get(url, (res) => { - let body = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { body += chunk; }); - res.on('end', () => { - try { resolve(JSON.parse(body)); } catch (err) { reject(err); } - }); - }).on('error', reject); - }); -} - -async function requestFirstJson(urls) { - let lastError; - for (const url of urls) { - try { - return await requestJson(url); - } catch (err) { - lastError = err; - } - } - throw lastError || new Error('No DevTools URL candidates.'); -} - -function freePort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const port = server.address().port; - server.close(() => resolve(port)); - }); - }); -} - -async function waitForDevtoolsHttp(port) { - const deadline = Date.now() + 20000; - while (Date.now() < deadline) { - try { - const version = await requestFirstJson([ - `http://127.0.0.1:${port}/json/version`, - `http://localhost:${port}/json/version`, - ]); - if (version && version.webSocketDebuggerUrl) return version; - } catch { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - throw new Error(`Timed out waiting for VS Code DevTools on port ${port}.`); -} - -class CdpClient { - constructor(wsUrl) { - this.wsUrl = wsUrl; - this.nextId = 1; - this.pending = new Map(); - this.listeners = new Map(); - } - - async connect() { - this.ws = new WebSocket(this.wsUrl); - this.ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg.id) { - const pending = this.pending.get(msg.id); - if (!pending) return; - this.pending.delete(msg.id); - if (msg.error) pending.reject(new Error(msg.error.message || JSON.stringify(msg.error))); - else pending.resolve(msg.result); - return; - } - const listeners = this.listeners.get(msg.method) || []; - for (const listener of listeners) listener(msg.params || {}, msg.sessionId || ''); - }; - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out connecting to DevTools WebSocket: ${this.wsUrl}`)), 10000); - this.ws.onopen = () => { - clearTimeout(timer); - resolve(); - }; - this.ws.onerror = (event) => { - clearTimeout(timer); - reject(event instanceof Error ? event : new Error(`DevTools WebSocket error: ${this.wsUrl}`)); - }; - }); - } - - on(method, listener) { - const listeners = this.listeners.get(method) || []; - listeners.push(listener); - this.listeners.set(method, listeners); - } - - send(method, params = {}, sessionId = '') { - const id = this.nextId++; - this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params })); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Timed out waiting for CDP ${method}`)); - }, 10000); - this.pending.set(id, { - resolve: (value) => { - clearTimeout(timer); - resolve(value); - }, - reject: (err) => { - clearTimeout(timer); - reject(err); - }, - }); - }); - } - - close() { - try { this.ws.close(); } catch {} - } -} - -// VS Code sometimes loses the race between "guess how to display this file" (which, for a raw -// serialized objmod binary, concludes "binary/unsupported, show a placeholder") and the extension -// finishing activation and registering wurst.objModPreview as the default editor for it — whichever -// wins, the file's tab is stuck on whatever it picked (it does not retry once the extension shows up -// late). Recovering from that requires actually driving the UI: open the command palette for real and -// run "Reopen Editor With..." against the currently-active (placeholder) tab. -async function pressKeyCombo(client, sessionId, keys) { - for (const k of keys) { - await client.send('Input.dispatchKeyEvent', { - type: 'rawKeyDown', modifiers: k.modifiers || 0, key: k.key, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk, - }, sessionId); - } - for (const k of [...keys].reverse()) { - await client.send('Input.dispatchKeyEvent', { - type: 'keyUp', modifiers: 0, key: k.key, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk, - }, sessionId); - } -} - -async function typeText(client, sessionId, text) { - for (const ch of text) { - await client.send('Input.dispatchKeyEvent', { type: 'char', text: ch, key: ch, unmodifiedText: ch }, sessionId); - await new Promise((resolve) => setTimeout(resolve, 15)); - } -} - -async function pressEnter(client, sessionId) { - await client.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, sessionId); - await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, sessionId); -} - -async function recoverStuckEditorPlaceholder(client, pageSessionId, userDataDir) { - log('recovering from the "guessed wrong editor" startup race via Reopen Editor With...'); - // CDP Input.dispatchKeyEvent delivers the event to Chromium's input pipeline regardless of OS-level - // focus, but VS Code's own keybinding dispatch appears to no-op silently if the BrowserWindow isn't - // the real foreground window (e.g. this terminal, or another VS Code window, has focus instead) — - // bring it to the front first (both the CDP way and, since that alone wasn't enough, the real Win32 - // SetForegroundWindow) so the synthetic keystrokes actually land on something listening. - await client.send('Page.bringToFront', {}, pageSessionId).catch(() => {}); - bringVsCodeWindowToForeground(userDataDir); - await new Promise((resolve) => setTimeout(resolve, 300)); - const CTRL = 2, SHIFT = 8; - await pressKeyCombo(client, pageSessionId, [ - { key: 'Control', code: 'ControlLeft', vk: 17, modifiers: CTRL | SHIFT }, - { key: 'Shift', code: 'ShiftLeft', vk: 16, modifiers: CTRL | SHIFT }, - { key: 'p', code: 'KeyP', vk: 80, modifiers: CTRL | SHIFT }, - ]); - await new Promise((resolve) => setTimeout(resolve, 400)); - await typeText(client, pageSessionId, '>Reopen Editor With'); - await new Promise((resolve) => setTimeout(resolve, 400)); - await pressEnter(client, pageSessionId); - await new Promise((resolve) => setTimeout(resolve, 600)); - await typeText(client, pageSessionId, 'WC3 Object Data'); - await new Promise((resolve) => setTimeout(resolve, 400)); - await pressEnter(client, pageSessionId); -} - -// eslint-disable-next-line sonarjs/cognitive-complexity -- TODO(lint-cleanup): pre-existing, tracked for a dedicated decomposition pass rather than a rushed refactor here. -async function waitForWebviewContext(client, userDataDir) { - const contexts = new Map(); - const attachedTargets = new Set(); - const sessionByTargetId = new Map(); - let pageSessionId; - let recoveryAttemptedAt = 0; - client.on('Runtime.executionContextCreated', ({ context }, sessionId) => { - if (context && context.id && sessionId) contexts.set(`${sessionId}:${context.id}`, { sessionId, context }); - }); - const startedAt = Date.now(); - const deadline = startedAt + timeoutMs; - let lastLog = 0; - while (Date.now() < deadline) { - const targets = await client.send('Target.getTargets').catch(() => undefined); - if (process.env.WURST_E2E_VERBOSE === '1' && Date.now() - lastLog > 3000) { - lastLog = Date.now(); - const summary = (targets?.targetInfos || []).map((t) => `${t.type}:${t.title || t.url || t.targetId}`).join(' | '); - console.log(`[verbose] targets: ${summary}; contexts known: ${contexts.size}; attached: ${attachedTargets.size}`); - } - for (const target of targets?.targetInfos || []) { - if (!target.targetId || attachedTargets.has(target.targetId)) continue; - if (!['page', 'iframe', 'webview'].includes(target.type)) continue; - try { - const attached = await client.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); - if (attached?.sessionId) { - attachedTargets.add(target.targetId); - sessionByTargetId.set(target.targetId, attached.sessionId); - await client.send('Runtime.enable', {}, attached.sessionId); - if (process.env.WURST_E2E_SCREENSHOT === '1') await client.send('Page.enable', {}, attached.sessionId).catch(() => {}); - if (process.env.WURST_E2E_VERBOSE === '1') console.log(`[verbose] attached to ${target.type}:${target.title || target.url} sessionId=${attached.sessionId}`); - } - } catch (err) { - attachedTargets.add(target.targetId); - if (process.env.WURST_E2E_VERBOSE === '1') console.log(`[verbose] attach failed for ${target.type}:${target.title || target.url}: ${err.message}`); - } - } - // The page's title only becomes "[Extension Development Host] - ..." once the workbench - // has actually rendered — well *after* it was first attached (when its title was still the bare - // vscode-file:// workbench.html URL) — so this has to be re-checked against the latest target - // info every iteration, not just once at attach time. - for (const target of targets?.targetInfos || []) { - if (target.type === 'page' && /Extension Development Host/.test(target.title || '') && sessionByTargetId.has(target.targetId)) { - pageSessionId = sessionByTargetId.get(target.targetId); - } - } - for (const { sessionId, context } of contexts.values()) { - const result = await client.send('Runtime.evaluate', { - contextId: context.id, - expression: '!!window.__wurstModelThumbDebug', - returnByValue: true, - }, sessionId).catch((err) => ({ __err: err.message })); - if (result?.result?.value) return { sessionId, contextId: context.id }; - } - // Give the extension a real chance to activate & win the race on its own first; only drive the - // UI recovery once, and only after a generous head start. - if (pageSessionId && !recoveryAttemptedAt && Date.now() - startedAt > 8000) { - recoveryAttemptedAt = Date.now(); - await recoverStuckEditorPlaceholder(client, pageSessionId, userDataDir).catch((err) => { - log(`recovery attempt failed (continuing to wait anyway): ${err.message}`); - }); - if (process.env.WURST_E2E_SCREENSHOT === '1') { - try { - await client.send('Page.enable', {}, pageSessionId).catch(() => {}); - const shot = await client.send('Page.captureScreenshot', { format: 'png' }, pageSessionId); - if (shot?.data) { - const outPath = path.join(os.tmpdir(), `wurst-e2e-post-recovery-${Date.now()}.png`); - fs.writeFileSync(outPath, Buffer.from(shot.data, 'base64')); - console.log(`[verbose] post-recovery screenshot: ${outPath}`); - } - } catch (err) { - console.log(`[verbose] post-recovery screenshot failed: ${err.message}`); - } - } - } - if (process.env.WURST_E2E_SCREENSHOT === '1' && Date.now() - (waitForWebviewContext._lastShot || 0) > 8000) { - waitForWebviewContext._lastShot = Date.now(); - for (const { sessionId } of contexts.values()) { - try { - const shot = await client.send('Page.captureScreenshot', { format: 'png' }, sessionId); - if (shot?.data) { - const outPath = path.join(os.tmpdir(), `wurst-e2e-shot-${Date.now()}.png`); - fs.writeFileSync(outPath, Buffer.from(shot.data, 'base64')); - console.log(`[verbose] screenshot saved: ${outPath}`); - } - } catch (err) { - console.log(`[verbose] screenshot failed: ${err.message}`); - } - } - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - const targets = await client.send('Target.getTargets').catch(() => undefined); - const summary = (targets?.targetInfos || []).map((target) => `${target.type}:${target.title || target.url || target.targetId}`).join(' | '); - throw new Error(`Timed out waiting for objmod webview debug hook. Targets: ${summary}`); -} - -async function evalInContext(client, sessionId, contextId, expression) { - const result = await client.send('Runtime.evaluate', { - contextId, - expression, - awaitPromise: true, - returnByValue: true, - }, sessionId); - if (result.exceptionDetails) { - throw new Error(result.exceptionDetails.text || 'webview evaluation failed'); - } - return result.result.value; -} - -async function waitForEval(client, sessionId, contextId, expression, predicate, label, waitMs = timeoutMs) { - const deadline = Date.now() + waitMs; - let last; - while (Date.now() < deadline) { - last = await evalInContext(client, sessionId, contextId, expression); - if (predicate(last)) return last; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(last)}`); -} - -// Real, OS-trusted keystrokes via CDP Input — NOT execCommand and NOT a synthesized DOM KeyboardEvent. -// This is the whole point of this script: a JS-invoked execCommand('copy') silently no-ops without a -// genuine user gesture, so it can't tell us anything a plain debug-hook call couldn't already. -async function dispatchRawKey(client, sessionId, { key, code, windowsVirtualKeyCode, modifiers = 0 }) { - await client.send('Input.dispatchKeyEvent', { - type: 'rawKeyDown', modifiers, key, code, windowsVirtualKeyCode, nativeVirtualKeyCode: windowsVirtualKeyCode, - }, sessionId); - await client.send('Input.dispatchKeyEvent', { - type: 'keyUp', modifiers, key, code, windowsVirtualKeyCode, nativeVirtualKeyCode: windowsVirtualKeyCode, - }, sessionId); -} - -const CTRL_MODIFIER = 2; // CDP Input modifier bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8 - -async function ctrlKeyCombo(client, sessionId, letterKey, letterCode, vk) { - await client.send('Input.dispatchKeyEvent', { - type: 'rawKeyDown', modifiers: CTRL_MODIFIER, key: 'Control', code: 'ControlLeft', windowsVirtualKeyCode: 17, nativeVirtualKeyCode: 17, - }, sessionId); - await dispatchRawKey(client, sessionId, { key: letterKey, code: letterCode, windowsVirtualKeyCode: vk, modifiers: CTRL_MODIFIER }); - await client.send('Input.dispatchKeyEvent', { - type: 'keyUp', modifiers: 0, key: 'Control', code: 'ControlLeft', windowsVirtualKeyCode: 17, nativeVirtualKeyCode: 17, - }, sessionId); -} - -async function ctrlC(client, sessionId) { await ctrlKeyCombo(client, sessionId, 'c', 'KeyC', 67); } -async function ctrlX(client, sessionId) { await ctrlKeyCombo(client, sessionId, 'x', 'KeyX', 88); } -async function ctrlV(client, sessionId) { await ctrlKeyCombo(client, sessionId, 'v', 'KeyV', 86); } - -async function main() { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-objmod-clip-e2e-user-')); - const extensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-objmod-clip-e2e-ext-')); - const code = findCode(); - const devtoolsPort = Number(process.env.WURST_OBJMOD_E2E_DEBUG_PORT || await freePort()); - // extension.ts's openObjModE2eFixture() explicitly opens this file with the wurst.objModPreview - // editor on activation (see WURST_OBJMOD_E2E_FILE there) — this is what actually avoids the CLI-arg - // open race (VS Code guessing "binary file" before the extension finishes registering the custom - // editor), not the various UI-automation recovery/foreground tricks below (which stayed in as a - // defensive fallback, but shouldn't be needed once this env var is set correctly). - const childEnv = { ...process.env, WURST_OBJMOD_E2E_PROJECT: projectPath, WURST_OBJMOD_E2E_FILE: objmodFile }; - delete childEnv.ELECTRON_RUN_AS_NODE; - log(`code=${code}`); - log(`project=${projectPath}`); - log(`file=${objmodFile}`); - log(`devtoolsPort=${devtoolsPort}`); - - const child = spawnCode(code, [ - '--new-window', - '--skip-welcome', - '--skip-release-notes', - '--disable-workspace-trust', - `--remote-debugging-port=${devtoolsPort}`, - `--user-data-dir=${userDataDir}`, - `--extensions-dir=${extensionsDir}`, - `--extensionDevelopmentPath=${root}`, - projectPath, - objmodFile, - ], childEnv); - - let stderr = ''; - child.stderr.on('data', (chunk) => { - stderr += String(chunk); - if (stderr.length > 8000) stderr = stderr.slice(stderr.length - 8000); - }); - - let client; - try { - log('waiting for DevTools HTTP'); - const version = await waitForDevtoolsHttp(devtoolsPort); - log('connecting DevTools WebSocket'); - client = new CdpClient(version.webSocketDebuggerUrl); - await client.connect(); - log('waiting for objmod webview'); - const { sessionId, contextId } = await waitForWebviewContext(client, userDataDir); - // Clipboard-relevant keystrokes need the window to actually be the OS foreground window (see - // the comment in recoverStuckEditorPlaceholder) — keep it in front for the rest of the run. - await client.send('Page.bringToFront', {}, sessionId).catch(() => {}); - bringVsCodeWindowToForeground(userDataDir); - - log('selecting fixture object'); - const selected = await waitForEval( - client, sessionId, contextId, - 'window.__wurstModelThumbDebug.selectObject("Z001")', - (value) => value === true, - 'Z001 fixture object selectable', - 15000, - ); - assert.equal(selected, true, 'fixture object should be selectable'); - - log('opening the tooltip field editor'); - await waitForEval( - client, sessionId, contextId, - 'window.__wurstModelThumbDebug.openFirstTooltipField()', - (value) => value === true, - 'a .tt-collapsed tooltip field to exist and open', - 15000, - ); - await waitForEval( - client, sessionId, contextId, - 'window.__wurstModelThumbDebug.getEditableBodyText()', - (value) => typeof value === 'string' && value.length > 0, - 'editable tooltip body to contain the fixture text', - 5000, - ); - const originalText = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.getEditableBodyText()'); - log(`editable body text: ${JSON.stringify(originalText)}`); - assert.ok(originalText && originalText.includes('Copy Paste Test'), `unexpected fixture text: ${JSON.stringify(originalText)}`); - - // Sanity check the floating toolbar sits beside the box, not detached off to the side (this - // was a real regression: the toolbar used to anchor to the outer row, which also contains the - // source-pill, throwing its position off by however wide that pill was). - const toolbarRect = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.getFloatToolbarRect()'); - const boxRect = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.getEditableBoxRect()'); - assert.ok(toolbarRect && boxRect, 'expected both the floating toolbar and the editable box to be present'); - const rectGap = Math.abs(toolbarRect.right - boxRect.right); - assert.ok(rectGap < 40, `floating toolbar should anchor near the box's right edge, got gap=${rectGap}px (toolbar=${JSON.stringify(toolbarRect)}, box=${JSON.stringify(boxRect)})`); - log(`toolbar anchored correctly (gap=${rectGap}px)`); - - // ---- COPY: select all, real Ctrl+C, clear the box, real Ctrl+V, expect the text back ---- - log('testing copy + paste'); - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.selectAllInEditableBody()'); - await new Promise((resolve) => setTimeout(resolve, 150)); - await ctrlC(client, sessionId); - const clipboardAfterCopy = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.readClipboardText()'); - log(`clipboard read-back right after Ctrl+C: ${JSON.stringify(clipboardAfterCopy)}`); - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.setEditableBodyText("")'); - const clearedForCopy = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.getEditableBodyText()'); - assert.equal(clearedForCopy, '', 'body should be empty right before the paste-back check'); - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.focusEditableBody()'); - await ctrlV(client, sessionId); - const afterPaste = await waitForEval( - client, sessionId, contextId, - 'window.__wurstModelThumbDebug.getEditableBodyText()', - (value) => typeof value === 'string' && value.length > 0, - 'pasted text to appear after Ctrl+V', - 5000, - ); - assert.ok(afterPaste.includes('Copy Paste Test'), `COPY+PASTE FAILED: expected pasted text to include the copied text, got ${JSON.stringify(afterPaste)}`); - const afterPasteHtml = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.getEditableBodyHtml()'); - assert.ok(/color:\s*#ffcc00/i.test(afterPasteHtml), `COLOR ROUND-TRIP FAILED: pasted text lost its WC3 color code, got HTML ${JSON.stringify(afterPasteHtml)}`); - log('copy + paste OK (color preserved)'); - - // ---- CUT: restore original, select all, real Ctrl+X, expect empty, then Ctrl+V restores it ---- - log('testing cut + paste'); - await evalInContext(client, sessionId, contextId, `window.__wurstModelThumbDebug.setEditableBodyText(${JSON.stringify(originalText)})`); - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.selectAllInEditableBody()'); - await new Promise((resolve) => setTimeout(resolve, 400)); - const preCutDebug = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.getSelectionDebugInfo()'); - log(`pre-cut selection/focus state: ${JSON.stringify(preCutDebug)}`); - await ctrlX(client, sessionId); - const afterCut = await waitForEval( - client, sessionId, contextId, - 'window.__wurstModelThumbDebug.getEditableBodyText()', - (value) => value === '', - 'body to be emptied by Ctrl+X', - 5000, - ); - assert.equal(afterCut, '', 'CUT FAILED: body should be empty immediately after Ctrl+X'); - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.focusEditableBody()'); - await ctrlV(client, sessionId); - const afterCutPaste = await waitForEval( - client, sessionId, contextId, - 'window.__wurstModelThumbDebug.getEditableBodyText()', - (value) => typeof value === 'string' && value.length > 0, - 'pasted text to reappear after cut + Ctrl+V', - 5000, - ); - assert.ok(afterCutPaste.includes('Copy Paste Test'), `CUT+PASTE FAILED: expected the cut text back, got ${JSON.stringify(afterCutPaste)}`); - log('cut + paste OK'); - - log('ALL CLIPBOARD CHECKS PASSED'); - } finally { - client?.close(); - await killProcessTree(child, userDataDir); - cleanupTempDir(userDataDir); - cleanupTempDir(extensionsDir); - cleanupTempDir(generated.dir); - if (stderr) console.error(stderr); - } -} - -main().catch((err) => { - console.error(err); - process.exitCode = 1; -}); diff --git a/scripts/objmod-thumbnail-e2e.js b/scripts/objmod-thumbnail-e2e.js deleted file mode 100644 index 309dfcf..0000000 --- a/scripts/objmod-thumbnail-e2e.js +++ /dev/null @@ -1,1164 +0,0 @@ -'use strict'; - -/** - * Local-only VS Code extension e2e for objmod thumbnails and both asset-browser variants. - * - * Enable explicitly, never in CI: - * $env:WURST_OBJMOD_E2E='1' - * npm run test:e2e:objmod-thumbs:local - * npm run test:e2e:asset-browser-code:local - * - * Optional knobs: - * WURST_OBJMOD_E2E_PROJECT defaults to ./e2e - * WURST_OBJMOD_E2E_FILE defaults to ./e2e/war3map.w3u - * WURST_OBJMOD_E2E_CODE_FILE optional .wurst file used for the code-launched asset-browser check - * WURST_OBJMOD_E2E_CODE Code.exe path, if it cannot be found - * WURST_OBJMOD_E2E_COUNT max visible thumbnails to assert, default all visible - * WURST_OBJMOD_E2E_SEARCH optional model-catalog search query - * WURST_OBJMOD_E2E_MAX_MS max warm per-thumbnail lifecycle, default 200ms - * WURST_OBJMOD_E2E_TIMEOUT_MS total wait timeout, default 90000 - * WURST_OBJMOD_E2E_FONT_ONLY stop after verifying a configured tooltip font - * WURST_OBJMOD_E2E_FONT optional local .ttf copied into the generated fixture and configured - */ - -const assert = require('assert'); -const childProcess = require('child_process'); -const fs = require('fs'); -const http = require('http'); -const net = require('net'); -const os = require('os'); -const path = require('path'); - -const root = path.resolve(__dirname, '..'); -const enabled = process.env.WURST_OBJMOD_E2E === '1' || process.env.WURST_LOCAL_E2E === '1'; -const codeOnly = process.argv.includes('--code-only'); - -if (!enabled) { - console.log('local objmod thumbnail e2e skipped (set WURST_OBJMOD_E2E=1 to enable)'); - process.exit(0); -} - -if (typeof WebSocket !== 'function') { - throw new Error('This harness needs Node with global WebSocket support (Node 22+).'); -} - -const defaultProjectPath = path.join(root, 'e2e'); -const defaultObjmodFile = path.join(defaultProjectPath, 'war3map.w3u'); -let projectPath = process.env.WURST_OBJMOD_E2E_PROJECT || defaultProjectPath; -let objmodFile = process.env.WURST_OBJMOD_E2E_FILE || defaultObjmodFile; -let codeAssetFile = process.env.WURST_OBJMOD_E2E_CODE_FILE || ''; -const sampleCountRaw = process.env.WURST_OBJMOD_E2E_COUNT; -const sampleCount = sampleCountRaw ? Number(sampleCountRaw) : 0; -const sampleLimit = Number.isFinite(sampleCount) && sampleCount > 0 ? sampleCount : Number.POSITIVE_INFINITY; -const searchQuery = process.env.WURST_OBJMOD_E2E_SEARCH || ''; -const maxThumbnailMs = Number(process.env.WURST_OBJMOD_E2E_MAX_MS || 200); -// A concurrently running VS Code installer holds the vscode-updating mutex; new isolated profiles -// wait up to 30 seconds before creating their first renderer. Leave enough time after that for -// extension activation, CASC catalog loading, and the uncached worker renders. -const timeoutMs = Number(process.env.WURST_OBJMOD_E2E_TIMEOUT_MS || 90000); -const fontOnly = process.env.WURST_OBJMOD_E2E_FONT_ONLY === '1'; - -function writeGeneratedObjmodFixture() { - const { serializeObjMod } = require('casc-ts/formats'); - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-objmod-fixture-')); - fs.writeFileSync(path.join(dir, 'wurst.build'), 'projectName = objmod-e2e\n'); - const importedModelsDir = path.join(dir, 'imports', 'units'); - fs.mkdirSync(importedModelsDir, { recursive: true }); - const validModelFixture = path.join(root, 'wc3data', 'melon.mdx'); - assert.ok(fs.existsSync(validModelFixture), `Missing valid model fixture: ${validModelFixture}`); - for (const name of ['Footman.mdx', 'FootmanPortrait.mdx', 'CaptainFootman.mdx', 'confirmation.mdx', 'AltarOfKings.mdx']) { - fs.copyFileSync(validModelFixture, path.join(importedModelsDir, name)); - } - const localFont = process.env.WURST_OBJMOD_E2E_FONT; - if (localFont) { - assert.ok(fs.existsSync(localFont), `WURST_OBJMOD_E2E_FONT does not exist: ${localFont}`); - const fontName = 'tooltip-e2e.ttf'; - fs.copyFileSync(localFont, path.join(dir, fontName)); - const settingsDir = path.join(dir, '.vscode'); - fs.mkdirSync(settingsDir); - fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({ 'wurst.objModTooltipFont': fontName })); - } - const main = { - version: 3, - ext: '.w3a', - extended: true, - origObjs: [{ - baseId: 'Ahrp', - newId: null, - mods: [ - { fieldId: 'anam', varType: 'string', level: 0, dataPt: 0, value: 'Repair E2E Override', endToken: '\0\0\0\0' }, - ], - }], - customObjs: [{ - baseId: 'Ahrp', - newId: 'Z001', - mods: [ - { fieldId: 'anam', varType: 'string', level: 0, dataPt: 0, value: 'Repair E2E Custom', endToken: '\0\0\0\0' }, - ], - }], - }; - const skin = { - version: 3, - ext: '.w3a', - extended: true, - origObjs: [{ - baseId: 'Ahrp', - newId: null, - mods: [ - { fieldId: 'aart', varType: 'string', level: 0, dataPt: 0, value: 'ReplaceableTextures\\CommandButtons\\BTNRepair.blp', endToken: '\0\0\0\0' }, - ], - }], - customObjs: [], - }; - fs.writeFileSync(path.join(dir, 'war3map.w3a'), serializeObjMod(main)); - fs.writeFileSync(path.join(dir, 'war3mapSkin.w3a'), serializeObjMod(skin)); - const codeFile = path.join(dir, 'AssetBrowserE2e.wurst'); - fs.writeFileSync(codeFile, 'package AssetBrowserE2e\n\nconstant TEST_MODEL = "imports\\\\units\\\\Footman.mdx"\n'); - return { dir, file: path.join(dir, 'war3map.w3a'), codeFile }; -} - -let generatedFixtureDir = ''; -if (!process.env.WURST_OBJMOD_E2E_PROJECT && !process.env.WURST_OBJMOD_E2E_FILE) { - const generated = writeGeneratedObjmodFixture(); - generatedFixtureDir = generated.dir; - projectPath = generated.dir; - objmodFile = generated.file; - codeAssetFile = generated.codeFile; -} - -assert.ok(projectPath && fs.existsSync(projectPath), 'Set WURST_OBJMOD_E2E_PROJECT to a real Wurst project folder, or keep ./e2e present.'); -assert.ok(objmodFile && fs.existsSync(objmodFile), 'Set WURST_OBJMOD_E2E_FILE to a real .w3u/.w3a/... file, or keep ./e2e/war3map.w3u present.'); -if (codeAssetFile) assert.ok(fs.existsSync(codeAssetFile), `WURST_OBJMOD_E2E_CODE_FILE does not exist: ${codeAssetFile}`); -if (codeOnly) assert.ok(codeAssetFile, 'The code-only e2e needs the generated fixture or WURST_OBJMOD_E2E_CODE_FILE.'); - -function log(message) { - console.log(`[objmod-thumb-e2e] ${message}`); -} - -function codeCandidates() { - const explicit = process.env.WURST_OBJMOD_E2E_CODE; - const candidates = explicit ? [explicit] : []; - if (process.platform === 'win32') { - const local = process.env.LOCALAPPDATA || ''; - const programFiles = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)']].filter(Boolean); - candidates.push( - path.join(local, 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'), - path.join(local, 'Programs', 'Microsoft VS Code', 'Code.exe'), - ...programFiles.map((dir) => path.join(dir, 'Microsoft VS Code', 'bin', 'code.cmd')), - ...programFiles.map((dir) => path.join(dir, 'Microsoft VS Code', 'Code.exe')), - ); - } else if (process.platform === 'darwin') { - candidates.push('/Applications/Visual Studio Code.app/Contents/MacOS/Electron'); - } else { - candidates.push('code', 'code-insiders'); - } - if (!explicit && process.env.VSCODE_PATH) candidates.push(process.env.VSCODE_PATH); - return candidates; -} - -function findCode() { - if (process.platform === 'win32' && !process.env.WURST_OBJMOD_E2E_CODE) { - const localShim = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Microsoft VS Code', 'bin', 'code.cmd'); - const localExe = path.resolve(path.dirname(localShim), '..', 'Code.exe'); - if (fs.existsSync(localExe)) return localExe; - } - for (const candidate of codeCandidates()) { - if (!candidate) continue; - const clean = String(candidate).replace(/^['"]|['"]$/g, ''); - const normalized = /(?:^|[\\/])code\.cmd$/i.test(clean) - ? path.resolve(path.dirname(clean), '..', 'Code.exe') - : clean; - if (normalized.includes(path.sep) && !fs.existsSync(normalized)) continue; - return normalized; - } - throw new Error('Could not find VS Code. Set WURST_OBJMOD_E2E_CODE.'); -} - -function spawnCode(code, args, childEnv) { - if (process.platform === 'win32' && /\.cmd$/i.test(code)) { - return childProcess.spawn('cmd.exe', ['/d', '/c', code, ...args], { - env: childEnv, - stdio: ['ignore', 'ignore', 'pipe'], - }); - } - return childProcess.spawn(code, args, { - env: childEnv, - detached: process.platform !== 'win32', - stdio: ['ignore', 'ignore', 'pipe'], - }); -} - -function waitForExit(child, timeoutMs = 5000) { - if (child.exitCode != null || child.signalCode != null) return Promise.resolve(); - return new Promise((resolve) => { - const timer = setTimeout(resolve, timeoutMs); - child.once('exit', () => { - clearTimeout(timer); - resolve(); - }); - }); -} - -function bringVsCodeWindowToForeground(userDataDir) { - if (process.platform !== 'win32') return; - const result = childProcess.spawnSync('powershell.exe', [ - '-NoProfile', - '-File', path.join(__dirname, 'bring-to-foreground.ps1'), - '-Needle', userDataDir, - ], { encoding: 'utf8', windowsHide: true, timeout: 10000 }); - if (process.env.WURST_E2E_VERBOSE === '1') { - const stderrSuffix = result.stderr ? ' stderr=' + result.stderr.trim() : ''; - console.log(`[verbose] bringVsCodeWindowToForeground: ${(result.stdout || '').trim() || '(no matching window)'}${stderrSuffix}`); - } -} - -function windowsCodePidsForUserDataDir(userDataDir) { - if (process.platform !== 'win32' || !userDataDir) return []; - const result = childProcess.spawnSync('powershell.exe', [ - '-NoProfile', - '-Command', - '$needle=$env:WURST_E2E_USER_DATA_DIR; ' + - 'Get-CimInstance Win32_Process -Filter "name = \'Code.exe\'" | ' + - 'Where-Object { $_.CommandLine -like "*$needle*" } | ' + - 'ForEach-Object { $_.ProcessId }', - ], { - env: { ...process.env, WURST_E2E_USER_DATA_DIR: userDataDir }, - encoding: 'utf8', - windowsHide: true, - timeout: 10000, - }); - if (result.status !== 0) return []; - return result.stdout - .split(/\r?\n/) - .map((line) => Number(line.trim())) - .filter((pid) => Number.isInteger(pid) && pid > 0); -} - -async function waitForNoWindowsCodeProfile(userDataDir, timeoutMs = 5000) { - if (process.platform !== 'win32') return; - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!windowsCodePidsForUserDataDir(userDataDir).length) return; - await new Promise((resolve) => setTimeout(resolve, 100)); - } -} - -// eslint-disable-next-line sonarjs/cognitive-complexity -- TODO(lint-cleanup): pre-existing, tracked for a dedicated decomposition pass rather than a rushed refactor here. -async function killProcessTree(child, userDataDir) { - if (process.platform === 'win32') { - const pids = new Set(windowsCodePidsForUserDataDir(userDataDir)); - if (child?.pid && child.exitCode == null && child.signalCode == null) pids.add(child.pid); - for (const pid of pids) { - childProcess.spawnSync('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true, - timeout: 10000, - }); - } - await waitForNoWindowsCodeProfile(userDataDir); - } else { - if (!child?.pid || child.exitCode != null || child.signalCode != null) return; - try { process.kill(-child.pid, 'SIGTERM'); } catch {} - await waitForExit(child, 2000); - if (child.exitCode == null && child.signalCode == null) { - try { process.kill(-child.pid, 'SIGKILL'); } catch {} - } - } - await waitForExit(child, 5000); -} - -function cleanupTempDir(dir) { - if (!dir || !path.resolve(dir).startsWith(os.tmpdir())) return; - try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch {} -} - -function requestJson(url) { - return new Promise((resolve, reject) => { - http.get(url, (res) => { - let body = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { body += chunk; }); - res.on('end', () => { - try { resolve(JSON.parse(body)); } catch (err) { reject(err); } - }); - }).on('error', reject); - }); -} - -async function requestFirstJson(urls) { - let lastError; - for (const url of urls) { - try { - return await requestJson(url); - } catch (err) { - lastError = err; - } - } - throw lastError || new Error('No DevTools URL candidates.'); -} - -function freePort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const port = server.address().port; - server.close(() => resolve(port)); - }); - }); -} - -async function waitForDevtoolsHttp(port) { - const deadline = Date.now() + 20000; - while (Date.now() < deadline) { - try { - const version = await requestFirstJson([ - `http://127.0.0.1:${port}/json/version`, - `http://localhost:${port}/json/version`, - ]); - if (version && version.webSocketDebuggerUrl) return version; - } catch { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - throw new Error(`Timed out waiting for VS Code DevTools on port ${port}.`); -} - -class CdpClient { - constructor(wsUrl) { - this.wsUrl = wsUrl; - this.nextId = 1; - this.pending = new Map(); - this.listeners = new Map(); - this.browserDiagnostics = []; - this.on('Log.entryAdded', ({ entry }) => { - if (entry?.text) this.browserDiagnostics.push(entry.text); - }); - this.on('Runtime.consoleAPICalled', ({ type, args }) => { - const message = (args || []).map((arg) => arg.value ?? arg.description ?? '').join(' '); - if (message) this.browserDiagnostics.push(`${type}: ${message}`); - }); - } - - async connect() { - this.ws = new WebSocket(this.wsUrl); - this.ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg.id) { - const pending = this.pending.get(msg.id); - if (!pending) return; - this.pending.delete(msg.id); - if (msg.error) pending.reject(new Error(msg.error.message || JSON.stringify(msg.error))); - else pending.resolve(msg.result); - return; - } - const listeners = this.listeners.get(msg.method) || []; - for (const listener of listeners) listener(msg.params || {}, msg.sessionId || ''); - }; - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out connecting to DevTools WebSocket: ${this.wsUrl}`)), 10000); - this.ws.onopen = () => { - clearTimeout(timer); - resolve(); - }; - this.ws.onerror = (event) => { - clearTimeout(timer); - reject(event instanceof Error ? event : new Error(`DevTools WebSocket error: ${this.wsUrl}`)); - }; - }); - } - - on(method, listener) { - const listeners = this.listeners.get(method) || []; - listeners.push(listener); - this.listeners.set(method, listeners); - } - - send(method, params = {}, sessionId = '') { - const id = this.nextId++; - this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params })); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Timed out waiting for CDP ${method}`)); - }, 10000); - this.pending.set(id, { - resolve: (value) => { - clearTimeout(timer); - resolve(value); - }, - reject: (err) => { - clearTimeout(timer); - reject(err); - }, - }); - }); - } - - close() { - try { this.ws.close(); } catch {} - } -} - -// eslint-disable-next-line sonarjs/cognitive-complexity -- TODO(lint-cleanup): pre-existing, tracked for a dedicated decomposition pass rather than a rushed refactor here. -async function waitForWebviewContext(client, requireObjmod = true) { - const contexts = new Map(); - const attachedTargets = new Set(); - const sessionByTargetId = new Map(); - let pageSessionId = ''; - client.on('Runtime.executionContextCreated', ({ context }, sessionId) => { - if (context && context.id && sessionId) contexts.set(`${sessionId}:${context.id}`, { sessionId, context }); - }); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const targets = await client.send('Target.getTargets').catch(() => undefined); - for (const target of targets?.targetInfos || []) { - if (!target.targetId || attachedTargets.has(target.targetId)) continue; - if (!['page', 'iframe', 'webview'].includes(target.type)) continue; - try { - const attached = await client.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); - if (attached?.sessionId) { - attachedTargets.add(target.targetId); - sessionByTargetId.set(target.targetId, attached.sessionId); - await client.send('Runtime.enable', {}, attached.sessionId); - await client.send('Log.enable', {}, attached.sessionId).catch(() => undefined); - } - } catch { - attachedTargets.add(target.targetId); - } - } - for (const target of targets?.targetInfos || []) { - if (target.type === 'page' && sessionByTargetId.has(target.targetId)) { - pageSessionId = sessionByTargetId.get(target.targetId); - } - } - if (!requireObjmod && pageSessionId) { - return { pageSessionId, contexts, attachedTargets, sessionByTargetId }; - } - for (const { sessionId, context } of contexts.values()) { - const result = await client.send('Runtime.evaluate', { - contextId: context.id, - expression: '!!window.__wurstModelThumbDebug', - returnByValue: true, - }, sessionId).catch(() => undefined); - if (result?.result?.value) { - return { sessionId, contextId: context.id, pageSessionId, contexts, attachedTargets, sessionByTargetId }; - } - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - const targets = await client.send('Target.getTargets').catch(() => undefined); - const summary = (targets?.targetInfos || []).map((target) => `${target.type}:${target.title || target.url || target.targetId}`).join(' | '); - const wanted = requireObjmod ? 'objmod webview debug hook' : 'extension-host workbench'; - throw new Error(`Timed out waiting for ${wanted}. Targets: ${summary}`); -} - -async function evalInContext(client, sessionId, contextId, expression) { - const result = await client.send('Runtime.evaluate', { - contextId, - expression, - awaitPromise: true, - returnByValue: true, - }, sessionId); - if (result.exceptionDetails) { - throw new Error(result.exceptionDetails.text || 'webview evaluation failed'); - } - return result.result.value; -} - -async function waitForEval(client, sessionId, contextId, expression, predicate, label, waitMs = timeoutMs) { - const deadline = Date.now() + waitMs; - let last; - while (Date.now() < deadline) { - last = await evalInContext(client, sessionId, contextId, expression); - if (predicate(last)) return last; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(last)}`); -} - -async function pressKeyCombo(client, sessionId, keys) { - for (const key of keys) { - await client.send('Input.dispatchKeyEvent', { - type: 'rawKeyDown', - modifiers: key.modifiers || 0, - key: key.key, - code: key.code, - windowsVirtualKeyCode: key.vk, - nativeVirtualKeyCode: key.vk, - }, sessionId); - } - for (const key of [...keys].reverse()) { - await client.send('Input.dispatchKeyEvent', { - type: 'keyUp', - modifiers: 0, - key: key.key, - code: key.code, - windowsVirtualKeyCode: key.vk, - nativeVirtualKeyCode: key.vk, - }, sessionId); - } -} - -async function typeText(client, sessionId, value) { - for (const ch of value) { - await client.send('Input.dispatchKeyEvent', { type: 'char', text: ch, key: ch, unmodifiedText: ch }, sessionId); - await new Promise((resolve) => setTimeout(resolve, 15)); - } -} - -async function pressEnter(client, sessionId) { - await client.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, sessionId); - await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, sessionId); -} - -async function evalInSession(client, sessionId, expression) { - const result = await client.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId); - if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'workbench evaluation failed'); - return result.result.value; -} - -async function waitForSessionEval(client, sessionId, expression, predicate, label, waitMs = timeoutMs) { - const deadline = Date.now() + waitMs; - let last; - while (Date.now() < deadline) { - last = await evalInSession(client, sessionId, expression); - if (predicate(last)) return last; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(last)}`); -} - -async function attachUntrackedTargets(client, tracking) { - const targets = await client.send('Target.getTargets').catch(() => undefined); - for (const target of targets?.targetInfos || []) { - if (!target.targetId || tracking.attachedTargets.has(target.targetId)) continue; - if (!['page', 'iframe', 'webview'].includes(target.type)) continue; - try { - const attached = await client.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); - if (!attached?.sessionId) continue; - tracking.attachedTargets.add(target.targetId); - tracking.sessionByTargetId.set(target.targetId, attached.sessionId); - await client.send('Runtime.enable', {}, attached.sessionId); - await client.send('Log.enable', {}, attached.sessionId).catch(() => undefined); - } catch { - tracking.attachedTargets.add(target.targetId); - } - } -} - -async function waitForCodeAssetBrowserContext(client, tracking, waitMs = timeoutMs) { - const deadline = Date.now() + waitMs; - while (Date.now() < deadline) { - await attachUntrackedTargets(client, tracking); - for (const { sessionId, context } of tracking.contexts.values()) { - const result = await client.send('Runtime.evaluate', { - contextId: context.id, - expression: '!!window.__wurstCodeAssetBrowserDebug', - returnByValue: true, - }, sessionId).catch(() => undefined); - if (result?.result?.value) return { sessionId, contextId: context.id }; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error('Timed out waiting for the code-launched asset browser webview.'); -} - -async function assertCodeAssetBrowserSearch(client, pageSessionId, tracking, userDataDir) { - if (!codeAssetFile) { - log('code-launched asset-browser check skipped (set WURST_OBJMOD_E2E_CODE_FILE for custom fixtures)'); - return; - } - assert.ok(pageSessionId, 'extension-host workbench target should be attached'); - log('opening code-launched asset browser from a real Wurst CodeLens'); - await client.send('Page.bringToFront', {}, pageSessionId).catch(() => undefined); - bringVsCodeWindowToForeground(userDataDir); - await new Promise((resolve) => setTimeout(resolve, 300)); - const CTRL = 2; - await pressKeyCombo(client, pageSessionId, [ - { key: 'Control', code: 'ControlLeft', vk: 17, modifiers: CTRL }, - { key: 'p', code: 'KeyP', vk: 80, modifiers: CTRL }, - ]); - await new Promise((resolve) => setTimeout(resolve, 300)); - await typeText(client, pageSessionId, path.basename(codeAssetFile)); - await new Promise((resolve) => setTimeout(resolve, 300)); - await pressEnter(client, pageSessionId); - - const codeLensExpression = `(function () { - return Array.from(document.querySelectorAll('.codelens-decoration, .codelens-decoration a')) - .some(function (node) { return String(node.textContent || '').indexOf('Browse model') >= 0; }); - })()`; - await waitForSessionEval(client, pageSessionId, codeLensExpression, (value) => value === true, 'Browse model CodeLens', 15000); - const clicked = await evalInSession(client, pageSessionId, `(function () { - var node = Array.from(document.querySelectorAll('.codelens-decoration a, .codelens-decoration')) - .find(function (candidate) { return String(candidate.textContent || '').indexOf('Browse model') >= 0; }); - if (!node) return false; - node.click(); - return true; - })()`); - assert.equal(clicked, true, 'Browse model CodeLens should be clickable'); - - const codeBrowser = await waitForCodeAssetBrowserContext(client, tracking, 30000); - await evalInContext(client, codeBrowser.sessionId, codeBrowser.contextId, 'window.__wurstCodeAssetBrowserDebug.search("footman")'); - const state = await waitForEval( - client, - codeBrowser.sessionId, - codeBrowser.contextId, - 'window.__wurstCodeAssetBrowserDebug.state()', - (value) => value && value.query === 'footman' && Array.isArray(value.results) && value.results.length > 0, - 'code-launched footman asset search results', - 15000, - ); - assert.equal(state.activeTab, 'model', 'model string CodeLens should open the Models tab'); - assert.ok( - state.results.some((entry) => /footman/i.test(`${entry.label} ${entry.value}`)), - `code-launched asset search should return Footman: ${JSON.stringify(state.results)}`, - ); - assert.ok( - state.results.every((entry) => /footm[ae]n/i.test(`${entry.label} ${entry.value}`)), - `code-launched asset search should not contain unrelated fuzzy noise: ${JSON.stringify(state.results)}`, - ); - for (let i = 1; i < state.results.length; i++) { - assert.ok( - state.results[i - 1].score <= state.results[i].score, - `code-launched asset search scores should be sorted: ${JSON.stringify(state.results)}`, - ); - } - log(`code-launched footman search returned ${state.results.length} relevance-sorted results`); -} - -async function assertTooltipFont(client, sessionId, contextId) { - const status = await evalInContext(client, sessionId, contextId, `(async function () { - var family = 'WurstProjectTooltip'; - var face = Array.from(document.fonts).find(function (candidate) { return candidate.family === family; }); - if (!face) return { configured: false }; - var resource = {}; - try { - var rules = Array.from(document.styleSheets).flatMap(function (sheet) { return Array.from(sheet.cssRules || []); }); - var fontRule = rules.find(function (rule) { return rule.cssText && rule.cssText.indexOf(family) >= 0 && rule.style && rule.style.src; }); - resource.src = fontRule ? fontRule.style.src : ''; - var match = resource.src.match(/url\\(["']?([^"')]+)["']?\\)/); - if (match) { - var response = await fetch(match[1]); - var bytes = await response.arrayBuffer(); - resource.ok = response.ok; - resource.status = response.status; - resource.type = response.type; - resource.bytes = bytes.byteLength; - resource.magic = Array.from(new Uint8Array(bytes.slice(0, 4))); - } - } catch (error) { - resource.error = String(error); - } - try { - var loadedFaces = await document.fonts.load('16px "WurstProjectTooltip"'); - var target = document.querySelector('.tt-collapsed-box, .tt-preview'); - var computed = target ? getComputedStyle(target).fontFamily : ''; - var outside = Array.from(document.querySelectorAll('td.id, td.num, td.value, td.label, .cell-edit-val')) - .filter(function (candidate) { return !candidate.closest('.tt-collapsed-box, .tt-preview'); }) - .slice(0, 50) - .map(function (candidate) { - return { - selector: candidate.tagName.toLowerCase() + '.' + candidate.className, - font: getComputedStyle(candidate).fontFamily, - text: String(candidate.textContent || '').trim().slice(0, 40), - }; - }); - return { - configured: true, - loaded: loadedFaces.some(function (candidate) { return candidate.family === family; }), - status: face.status, - computed: computed, - applied: target ? computed.indexOf(family) >= 0 : null, - editorFont: getComputedStyle(document.documentElement).getPropertyValue('--vscode-editor-font-family').trim(), - outsideUsingTooltipFont: outside.filter(function (candidate) { return candidate.font.indexOf(family) >= 0; }), - resource: resource, - }; - } catch (error) { - return { configured: true, loaded: false, status: face.status, error: String(error), resource: resource }; - } - })()`); - if (!status.configured) { - log('tooltip font not configured; font assertion skipped'); - return; - } - status.diagnostics = client.browserDiagnostics.filter((message) => /font|ots/i.test(message)).slice(-20); - assert.equal(status.error, undefined, `configured tooltip font failed to load: ${JSON.stringify(status)}`); - assert.equal(status.loaded, true, `configured tooltip font did not produce a loaded face: ${JSON.stringify(status)}`); - assert.equal(status.status, 'loaded', `configured tooltip font has unexpected status: ${JSON.stringify(status)}`); - assert.equal(status.applied, true, `configured tooltip font is not applied to the tooltip box: ${JSON.stringify(status)}`); - assert.deepEqual(status.outsideUsingTooltipFont, [], `configured tooltip font leaked outside tooltip boxes: ${JSON.stringify(status)}`); - log(`tooltip font loaded and scoped (${status.computed}); editor font=${status.editorFont || '(default)'}`); -} - -async function assertObjmodEditorBasics(client, sessionId, contextId) { - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.forceNarrowLayout(true)'); - - const layout = await waitForEval( - client, - sessionId, - contextId, - 'window.__wurstModelThumbDebug.layout()', - (value) => value && value.listVisible && value.detailsVisible && value.stacked, - 'stacked narrow objmod layout', - 5000, - ); - assert.ok(layout.details.height >= 200, `details pane should remain visible, got ${JSON.stringify(layout.details)}`); - - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.forceNarrowLayout(false)'); - - // Side-by-side is the layout the editor is actually meant to be used in, so unforcing narrow has to - // put the browse list back beside (not above) the details pane. - const sideBySide = await waitForEval( - client, - sessionId, - contextId, - 'window.__wurstModelThumbDebug.layout()', - (value) => value && value.listVisible && value.detailsVisible && !value.stacked, - 'side-by-side objmod layout', - 5000, - ); - assert.ok( - sideBySide.list.right <= sideBySide.details.left + 8, - `browse list should sit left of the details pane, got ${JSON.stringify({ list: sideBySide.list, details: sideBySide.details })}`, - ); - assert.ok( - sideBySide.list.width <= sideBySide.editor.width * 0.5, - `browse list should never take half the editor, got ${sideBySide.list.width} of ${sideBySide.editor.width}`, - ); - - // Density toggle: compact is the default, spacious is one click away and restores taller browse - // rows, and the whole thing is a class (no re-render), so a live row node stays valid across - // the switch. Driven through the real header button rather than a debug-only hook. - const density = await evalInContext(client, sessionId, contextId, `(function () { - var row = document.querySelector('.object-row'); - var btn = document.getElementById('density-toggle'); - if (!row || !btn) return null; - var startedCozy = document.body.classList.contains('density-cozy'); - var compactHeight = row.getBoundingClientRect().height; - btn.click(); - var cozyHeight = row.getBoundingClientRect().height; - var cozy = document.body.classList.contains('density-cozy'); - var cozyChecked = btn.getAttribute('aria-checked'); - var role = btn.getAttribute('role'); - var label = btn.getAttribute('aria-label'); - btn.click(); - return { - startedCozy: startedCozy, - compactHeight: compactHeight, - cozyHeight: cozyHeight, - cozy: cozy, - cozyChecked: cozyChecked, - role: role, - label: label, - restored: document.body.classList.contains('density-cozy'), - restoredChecked: btn.getAttribute('aria-checked'), - }; - })()`); - assert.ok(density, 'objmod header should expose a density toggle beside the save badge'); - assert.equal(density.startedCozy, false, 'compact should be the default density'); - assert.equal(density.cozy, true, 'clicking the density toggle should switch to the spacious scale'); - assert.equal(density.role, 'switch', 'the density control should expose itself as a switch'); - assert.equal(density.label, 'Spacious density', 'the density switch should have a clear accessible name'); - assert.equal(density.cozyChecked, 'true', 'the density switch should expose spacious as checked'); - assert.equal(density.restored, false, 'clicking the density toggle again should return to compact'); - assert.equal(density.restoredChecked, 'false', 'the density switch should expose compact as unchecked'); - assert.ok( - density.cozyHeight > density.compactHeight, - `spacious browse rows should be taller than compact ones, got ${density.cozyHeight} vs ${density.compactHeight}`, - ); - - // Clearing the object search must leave the selected object on screen. Filtering the tree clamps - // its scrollTop to ~0, so restoring that value once the full tree is back used to dump the user at - // the top with their selection hundreds of rows below. Driven through the real search input. - const searchScroll = await evalInContext(client, sessionId, contextId, `(async function () { - var tree = document.getElementById('tree'); - var search = document.getElementById('search'); - if (!tree || !search) return { skipped: 'no tree/search element' }; - var frame = function () { return new Promise(function (r) { requestAnimationFrame(function () { requestAnimationFrame(r); }); }); }; - var setQuery = async function (value) { - search.value = value; - search.dispatchEvent(new Event('input', { bubbles: true })); - await frame(); - }; - await setQuery(''); - var rows = tree.querySelectorAll('.object-row'); - if (rows.length < 2) return { skipped: 'fixture has too few objects' }; - // The bug needs a tree taller than its own viewport. The checked-in fixture is small enough to - // fit entirely on screen, so cap the viewport instead of requiring a huge map — the clamp-on- - // filter behaviour being tested is identical either way. Restored before returning. - var forcedHeight = tree.scrollHeight <= tree.clientHeight + 20; - if (forcedHeight) { tree.style.maxHeight = '60px'; await frame(); } - try { - if (tree.scrollHeight <= tree.clientHeight + 20) return { skipped: 'could not make the tree scroll' }; - var last = rows[rows.length - 1]; - last.click(); - var nameEl = last.querySelector('.object-name'); - var name = nameEl ? nameEl.textContent.trim() : ''; - if (!name) return { skipped: 'last row has no name to search for' }; - await setQuery(name); - await setQuery(''); - var active = tree.querySelector('.object-row.active'); - if (!active) return { skipped: 'selection did not survive the search' }; - var a = active.getBoundingClientRect(), t = tree.getBoundingClientRect(); - return { - visible: a.top >= t.top - 1 && a.bottom <= t.bottom + 1, - forcedHeight: forcedHeight, - rowOffset: Math.round(a.top - t.top), - treeHeight: Math.round(t.height), - scrollTop: Math.round(tree.scrollTop), - }; - } finally { - if (forcedHeight) tree.style.maxHeight = ''; - } - })()`); - if (searchScroll.skipped) { - console.log(`[objmod-thumb-e2e] search-scroll check skipped: ${searchScroll.skipped}`); - } else { - assert.ok( - searchScroll.visible, - `clearing the object search should keep the selected row in view, got ${JSON.stringify(searchScroll)}`, - ); - } - - const state = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.state()'); - if (generatedFixtureDir) { - assert.equal(state.fileInfo && state.fileInfo.mainName, 'war3map.w3a', 'main sibling should be reported'); - assert.equal(state.fileInfo && state.fileInfo.skinName, 'war3mapSkin.w3a', 'skin sibling should be reported'); - } - assert.equal(state.inert3dPlaceholders, 0, 'iconless model objects must use model-thumb slots, not inert 3D placeholders'); - - if (generatedFixtureDir) { - const selectedAhrp = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.selectObject("Ahrp")'); - assert.equal(selectedAhrp, true, 'Ahrp fixture object should be selectable'); - const ahrpRows = await waitForEval( - client, - sessionId, - contextId, - 'window.__wurstModelThumbDebug.detailsRows()', - (rows) => Array.isArray(rows) && rows.length > 2 && rows.some((row) => String(row.fieldId).toLowerCase() === 'anam' && row.overridden), - 'Ahrp detail rows', - 15000, - ); - assert.ok(ahrpRows.some((row) => /tooltip/i.test(row.label) || String(row.fieldId).toLowerCase() === 'atp1'), 'Ahrp should expose tooltip/base text rows'); - assert.ok(ahrpRows.some((row) => !row.overridden && row.editable), 'Ahrp should expose editable base-only rows'); - assert.ok(ahrpRows.some((row) => String(row.fieldId).toLowerCase() === 'aart' && row.overridden), 'skin sibling override should be merged into Ahrp rows'); - - const selectedCustom = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.selectObject("Z001")'); - assert.equal(selectedCustom, true, 'custom fixture object should be selectable'); - const customRows = await waitForEval( - client, - sessionId, - contextId, - 'window.__wurstModelThumbDebug.detailsRows()', - (rows) => Array.isArray(rows) && rows.length > 2 && rows.some((row) => !row.overridden && row.editable), - 'custom object base rows', - 15000, - ); - assert.ok(customRows.some((row) => String(row.fieldId).toLowerCase() === 'anam' && row.overridden), 'custom object should keep modified fields'); - } - - await waitForEval( - client, - sessionId, - contextId, - '!!document.querySelector(".tt-collapsed-box, .tt-preview")', - (value) => value === true, - 'rendered tooltip box', - 15000, - ); - await assertTooltipFont(client, sessionId, contextId); - - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.openModelAssetBrowser()'); - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("LordaeronTree")'); - const assetState = await waitForEval( - client, - sessionId, - contextId, - 'window.__wurstModelThumbDebug.state()', - (value) => value && value.assetBrowserOpen && value.assetBrowserCount > 0, - 'LordaeronTree model asset catalog entry', - 15000, - ); - assert.ok(assetState.visible.some((slot) => /LordaeronTree/i.test(slot.model)), 'LordaeronTree should appear as a model thumbnail slot'); - - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("footman")'); - const searchState = await waitForEval( - client, - sessionId, - contextId, - 'window.__wurstModelThumbDebug.state()', - (value) => value && Array.isArray(value.assetBrowserResults) && value.assetBrowserResults.some((entry) => /footman/i.test(entry.label)), - 'ranked footman asset search results', - 15000, - ); - const searchResults = searchState.assetBrowserResults; - assert.ok(searchResults.length > 0, 'footman search should return useful model results'); - assert.ok( - searchResults.every((entry) => /footm[ae]n/i.test(`${entry.label} ${entry.value}`)), - `footman search should not contain unrelated fuzzy noise: ${JSON.stringify(searchResults)}`, - ); - for (let i = 1; i < searchResults.length; i++) { - assert.ok( - searchResults[i - 1].score <= searchResults[i].score, - `asset search scores should be sorted by relevance: ${JSON.stringify(searchResults)}`, - ); - } - if (generatedFixtureDir) { - const fixtureScore = (name) => searchResults.find((entry) => entry.label.toLowerCase() === name.toLowerCase())?.score; - assert.equal(fixtureScore('Footman.mdx'), 0, 'exact filename search result should rank first'); - assert.equal(fixtureScore('FootmanPortrait.mdx'), 10, 'filename prefix search result should rank after exact matches'); - assert.equal(fixtureScore('CaptainFootman.mdx'), 20, 'filename substring search result should rank after prefix matches'); - assert.equal(fixtureScore('confirmation.mdx'), undefined, 'scattered letters in confirmation.mdx must not match footman'); - assert.equal(fixtureScore('AltarOfKings.mdx'), undefined, 'unrelated model names must not match footman'); - } - log(`footman search returned ${searchResults.length} relevance-sorted results; restoring the unfiltered catalog`); - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("")'); -} - -function terminalKeys(state) { - const terminals = new Set(); - for (const event of state.events) { - if (event.type === 'loaded' || event.type === 'missing' || event.type === 'failed') terminals.add(event.key); - } - for (const slot of state.visible || []) { - if (slot.loaded || slot.missing) terminals.add(slot.key); - } - return terminals; -} - -function durationsByKey(events) { - const starts = new Map(); - const out = new Map(); - for (const event of events) { - if (event.type === 'host-start' && !starts.has(event.key)) starts.set(event.key, event.at); - if ((event.type === 'loaded' || event.type === 'missing' || event.type === 'failed') && starts.has(event.key) && !out.has(event.key)) { - out.set(event.key, event.at - starts.get(event.key)); - } - } - return out; -} - -function profileForKey(events, key) { - return events - .filter((event) => event.key === key && (event.type === 'host-start' || event.type === 'render-start' || event.type === 'loaded' || event.type === 'missing' || event.type === 'failed' || event.type.startsWith('profile:'))) - .map((event) => { - const phase = event.type.replace(/^profile:/, ''); - const elapsed = event.elapsedMs == null ? '' : `@${event.elapsedMs}ms`; - const reason = event.reason ? ` reason=${event.reason}` : ''; - const detail = event.detail ? ' ' + event.detail : ''; - return `${phase}${elapsed}${detail}${reason}`; - }); -} - -function rendererModeForKey(events, key) { - const parsed = events.find((event) => event.key === key && event.type === 'profile:worker-parsed'); - if (!parsed?.detail) return 'unknown'; - try { - return JSON.parse(parsed.detail).isHD ? 'hd' : 'sd'; - } catch { - return 'unknown'; - } -} - -// eslint-disable-next-line sonarjs/cognitive-complexity -- TODO(lint-cleanup): pre-existing, tracked for a dedicated decomposition pass rather than a rushed refactor here. -async function main() { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-objmod-e2e-user-')); - const extensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-objmod-e2e-ext-')); - const code = findCode(); - const devtoolsPort = Number(process.env.WURST_OBJMOD_E2E_DEBUG_PORT || await freePort()); - const childEnv = { - ...process.env, - WURST_MODEL_THUMB_DISABLE_CACHE: '1', - WURST_OBJMOD_E2E_PROJECT: projectPath, - WURST_OBJMOD_E2E_FILE: objmodFile, - }; - if (codeOnly) delete childEnv.WURST_OBJMOD_E2E_FILE; - delete childEnv.ELECTRON_RUN_AS_NODE; - log(`code=${code}`); - log(`project=${projectPath}`); - log(`file=${objmodFile}`); - log(`devtoolsPort=${devtoolsPort}`); - - const launchArgs = [ - '--new-window', - '--skip-welcome', - '--skip-release-notes', - '--disable-updates', - '--disable-workspace-trust', - `--remote-debugging-port=${devtoolsPort}`, - `--user-data-dir=${userDataDir}`, - `--extensions-dir=${extensionsDir}`, - `--extensionDevelopmentPath=${root}`, - projectPath, - codeOnly ? codeAssetFile : objmodFile, - ]; - const child = spawnCode(code, launchArgs, childEnv); - - let stderr = ''; - let passed = false; - child.stderr.on('data', (chunk) => { - stderr += String(chunk); - if (stderr.length > 8000) stderr = stderr.slice(stderr.length - 8000); - }); - - let client; - try { - log('waiting for DevTools HTTP'); - const version = await waitForDevtoolsHttp(devtoolsPort); - if (process.env.WURST_E2E_VERBOSE === '1') { - console.log(`[verbose] launcher pid=${child.pid} exitCode=${child.exitCode} profilePids=${windowsCodePidsForUserDataDir(userDataDir).join(',')}`); - const pages = await requestFirstJson([ - `http://127.0.0.1:${devtoolsPort}/json/list`, - `http://localhost:${devtoolsPort}/json/list`, - ]).catch((error) => ({ error: error.message })); - console.log(`[verbose] initial /json/list: ${JSON.stringify(pages)}`); - } - log('connecting DevTools WebSocket'); - client = new CdpClient(version.webSocketDebuggerUrl); - await client.connect(); - log(codeOnly ? 'waiting for extension-host workbench' : 'waiting for objmod webview'); - const tracking = await waitForWebviewContext(client, !codeOnly); - if (codeOnly) { - await assertCodeAssetBrowserSearch(client, tracking.pageSessionId, tracking, userDataDir); - passed = true; - return; - } - const { sessionId, contextId, pageSessionId } = tracking; - log('asserting objmod editor basics'); - await assertObjmodEditorBasics(client, sessionId, contextId); - if (fontOnly) { - log('font-only check passed'); - passed = true; - return; - } - log('asserting model thumbnails'); - await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.openModelAssetBrowser()'); - if (searchQuery) { - await evalInContext( - client, - sessionId, - contextId, - `window.__wurstModelThumbDebug.searchModelAssetBrowser(${JSON.stringify(searchQuery)})`, - ); - } - - let initialKeys = []; - const violations = []; - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const state = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.state()'); - const visibleAll = state.visible.filter((slot) => slot.visible); - const visible = visibleAll.slice(0, sampleLimit); - if (!initialKeys.length && visible.length >= Math.min(sampleLimit, 8)) { - initialKeys = visible.map((slot) => slot.key); - log(`observing ${initialKeys.length} visible thumbnails`); - } - if (initialKeys.length) { - const terminals = terminalKeys(state); - for (const slot of visible.filter((slot) => initialKeys.includes(slot.key))) { - if (!terminals.has(slot.key) && !slot.pending) { - violations.push(`${slot.key} was neither pending nor terminal while visible`); - } - } - if (initialKeys.every((key) => terminals.has(key))) { - const terminalOrder = []; - const seenTerminal = new Set(); - for (const event of state.events) { - if (!(event.type === 'loaded' || event.type === 'missing' || event.type === 'failed') || !initialKeys.includes(event.key) || seenTerminal.has(event.key)) continue; - seenTerminal.add(event.key); - terminalOrder.push(event.key); - } - if (terminalOrder.slice(0, initialKeys.length).join('\n') !== initialKeys.join('\n')) { - console.error(`[objmod-thumb-e2e] expected order:\n${initialKeys.join('\n')}`); - console.error(`[objmod-thumb-e2e] actual order:\n${terminalOrder.slice(0, initialKeys.length).join('\n')}`); - } - assert.deepEqual(terminalOrder.slice(0, initialKeys.length), initialKeys, 'terminal thumbnail order must match visible DOM order'); - assert.equal(violations.length, 0, violations.join('\n')); - - const durations = durationsByKey(state.events); - const failures = []; - const numbers = []; - const visibleByKey = new Map(state.visible.map((slot) => [slot.key, slot])); - if (state.worker?.state !== 'ready') { - failures.push(`thumbnail worker did not start: ${JSON.stringify(state.worker || null)}`); - } - for (const key of initialKeys) { - const slot = visibleByKey.get(key); - if (!slot?.loaded) { - let reason = '{"reason":"no-loaded-img"}'; - if (slot?.reason) reason = JSON.stringify(slot.reason); - else if (slot?.missing) reason = '{"reason":"missing"}'; - failures.push(`${key}: expected a rendered thumbnail image, got ${reason}`); - } - } - const warmupKeys = new Set(); - const warmedModes = new Set(); - for (const key of initialKeys) { - if (!state.events.some((event) => event.key === key && event.type === 'render-start')) continue; - const mode = rendererModeForKey(state.events, key); - if (warmedModes.has(mode)) continue; - warmedModes.add(mode); - warmupKeys.add(key); - } - for (const key of initialKeys.filter((key) => !warmupKeys.has(key))) { - const ms = durations.get(key); - if (typeof ms !== 'number') failures.push(`${key}: missing duration`); - else { - numbers.push(ms); - if (Number.isFinite(maxThumbnailMs) && ms > maxThumbnailMs) { - failures.push(`${key}: ${ms}ms exceeded ${maxThumbnailMs}ms warm lifecycle budget`); - } - } - } - for (const key of initialKeys) { - const warmup = warmupKeys.has(key) ? ` ${rendererModeForKey(state.events, key)}-warmup` : ''; - log(`${key}${warmup} ${durations.get(key)}ms`); - } - if (failures.length) { - console.error(`objmod thumbnail e2e failures:\n${failures.join('\n')}`); - for (const key of initialKeys) { - const profile = profileForKey(state.events, key); - console.error(`[objmod-thumb-e2e] ${key} profile ${profile.join(' -> ')}`); - } - } - assert.equal(failures.length, 0, failures.join('\n')); - const maxObserved = numbers.length ? `${Math.max(...numbers)}ms` : 'n/a'; - log(`passed ${initialKeys.length} completed thumbnails, max observed=${maxObserved}`); - await assertCodeAssetBrowserSearch(client, pageSessionId, tracking, userDataDir); - passed = true; - return; - } - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - const state = await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.state()').catch(() => undefined); - if (state && initialKeys.length) { - const terminals = terminalKeys(state); - for (const key of initialKeys) { - if (terminals.has(key)) continue; - const slot = state.visible.find((candidate) => candidate.key === key); - console.error(`[objmod-thumb-e2e] unfinished ${key} slot=${JSON.stringify(slot || null)} profile ${profileForKey(state.events, key).join(' -> ')}`); - } - } - throw new Error(`Timed out waiting for ${initialKeys.length || 'visible'} thumbnail decisions.`); - } finally { - client?.close(); - await killProcessTree(child, userDataDir); - if (process.env.WURST_E2E_KEEP_TEMP === '1') { - log(`kept userDataDir=${userDataDir}`); - log(`kept extensionsDir=${extensionsDir}`); - if (generatedFixtureDir) log(`kept fixtureDir=${generatedFixtureDir}`); - } else { - cleanupTempDir(userDataDir); - cleanupTempDir(extensionsDir); - cleanupTempDir(generatedFixtureDir); - } - if (stderr && !passed) console.error(stderr); - } -} - -main().catch((err) => { - console.error(err); - process.exitCode = 1; -}); diff --git a/scripts/test-webview.js b/scripts/test-webview.js index ae0408c..ab2fda9 100644 --- a/scripts/test-webview.js +++ b/scripts/test-webview.js @@ -6,69 +6,21 @@ * It transpiles the real TypeScript files in-memory, then runs them against a tiny * DOM shim. Keep tests here for pure/lite webview behavior that should not need a * full VS Code integration launch. + * + * For behavior that needs a real browser (layout, CSS, events, the host<->webview message + * protocol), see the Playwright suite under e2e/ instead. */ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const ts = require('typescript'); const vm = require('vm'); -const root = path.resolve(__dirname, '..'); -const moduleCache = new Map(); - -function loadTsModule(relPath) { - const abs = path.resolve(root, relPath); - if (moduleCache.has(abs)) return moduleCache.get(abs).exports; - - const src = fs.readFileSync(abs, 'utf8'); - const js = ts.transpileModule(src, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - - const mod = { exports: {} }; - moduleCache.set(abs, mod); - const localRequire = (request) => { - if (request.startsWith('.')) { - const resolved = path.resolve(path.dirname(abs), request); - const withExt = fs.existsSync(resolved) ? resolved : `${resolved}.ts`; - return loadTsModule(path.relative(root, withExt)); - } - return require(request); - }; - new Function('exports', 'module', 'require', js)(mod.exports, mod, localRequire); - return mod.exports; -} +const { createTsLoader, sharedLoader, root } = require('../e2e/harness/tsLoader'); -function loadTsModuleWithMocks(relPath, mocks) { - const localCache = new Map(); - const load = (nextRelPath) => { - const abs = path.resolve(root, nextRelPath); - if (localCache.has(abs)) return localCache.get(abs).exports; - - const src = fs.readFileSync(abs, 'utf8'); - const js = ts.transpileModule(src, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, - }).outputText; - - const mod = { exports: {} }; - localCache.set(abs, mod); - const localRequire = (request) => { - if (Object.prototype.hasOwnProperty.call(mocks, request)) return mocks[request]; - if (request.startsWith('.')) { - const resolved = path.resolve(path.dirname(abs), request); - const withExt = fs.existsSync(resolved) ? resolved : `${resolved}.ts`; - const relative = path.relative(root, withExt).replace(/\\/g, '/'); - if (Object.prototype.hasOwnProperty.call(mocks, relative)) return mocks[relative]; - return load(relative); - } - return require(request); - }; - new Function('exports', 'module', 'require', js)(mod.exports, mod, localRequire); - return mod.exports; - }; - return load(relPath); -} +const loadTsModule = sharedLoader; +const moduleCache = sharedLoader.cache; +const loadTsModuleWithMocks = (relPath, mocks) => createTsLoader({ mocks })(relPath); function testAssetPathNormalization() { const { normalizeAssetPath } = loadTsModule('src/webview/assetPathUtils.ts'); @@ -1023,6 +975,40 @@ function testObjModTooltipPreviewHeaders() { assert.ok(detailsPanel.includes('renderWc3Colors(tooltipPreviewText(value, isTooltipTemplateField(mod)))'), 'tooltip collapse should restore the cleaned preview only for tooltip fields'); } +function testObjModSavedAndUsedTooltipColors() { + const messages = []; + const fieldDisplay = loadTsModuleWithMocks('src/webview/objModEditor/fieldDisplay.ts', { + './state': { + initial: { customColors: ['#ABCDEF', 'invalid', 'abcdef', '123456', 'ffcc00'] }, + ui: {}, + vscodeApi: { postMessage: (message) => messages.push(message) }, + }, + }); + + assert.deepEqual(fieldDisplay.customColors, ['abcdef', '123456'], 'saved colors should be normalized, deduplicated, and exclude presets'); + const colors = fieldDisplay.extractUsedColors( + '|cff000001one|r |C80000002two|r ||cffff0000 literal |c7f000003three|r ' + + '|cff000004four|r |cff000005five|r |czz000006invalid', + ); + assert.deepEqual(colors, ['000001', '000002', '000003', '000004', '000005'], 'every distinct valid WC3 color should be recognized in source order'); + assert.equal((fieldDisplay.usedColorSwatchesHtml(colors.map((hex) => `|cff${hex}x|r`).join('')).match(/tt-used-sw/g) || []).length, 5, 'the toolbar should render every recognized color'); + assert.deepEqual(fieldDisplay.extractUsedColors('|cff010203x|r|c80040506y|r|cff070809z|r', 2), ['010203', '040506'], 'an explicit color limit should still be honored'); + assert.deepEqual(fieldDisplay.extractUsedColors('||cffff0000 escaped'), [], 'an escaped literal pipe must not be mistaken for a color marker'); + + assert.equal(fieldDisplay.rememberCustomColor('#654321'), '654321'); + assert.deepEqual(fieldDisplay.customColors.slice(0, 3), ['654321', 'abcdef', '123456']); + assert.deepEqual(messages, [{ type: 'rememberCustomColor', color: '654321' }]); + assert.ok(fieldDisplay.customSwatchesHtml().includes('data-color="654321"'), 'remembered colors should render in the saved palette'); + fieldDisplay.rememberCustomColor('#654321'); + assert.equal(messages.length, 1, 'choosing the newest saved color again should not write duplicate state'); + + const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8'); + assert.ok(host.includes("context.globalState"), 'saved custom colors should follow the user across workspaces'); + assert.ok(host.includes("msg.type === 'rememberCustomColor'"), 'the objmod host should persist custom colors sent by the picker'); + assert.ok(host.includes('customColors,'), 'saved custom colors should be restored into new objmod webviews'); + assert.ok(host.includes('.tt-custom-colors[hidden] { display: none; }'), 'an empty saved palette should remain hidden despite its flex layout'); +} + function testObjModDensityAndTreeStyling() { const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8'); const webview = fs.readFileSync(path.join(root, 'src/webview/objModEditorWebview.ts'), 'utf8'); @@ -1040,7 +1026,7 @@ function testObjModDensityAndTreeStyling() { function testImportedAssetDedupeSafety() { const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8'); const support = fs.readFileSync(path.join(root, 'src/features/imageAssetSupport.ts'), 'utf8'); - const e2e = fs.readFileSync(path.join(root, 'scripts/objmod-thumbnail-e2e.js'), 'utf8'); + const e2e = fs.readFileSync(path.join(root, 'e2e/local/fixtures.js'), 'utf8'); assert.ok(!support.includes('hashImportedAsset'), 'asset dedupe must not mistake size+mtime metadata for a content hash'); assert.ok(!host.includes('opt.hash'), 'distinct imported files must not collapse through metadata collisions'); @@ -1053,6 +1039,23 @@ function testImportedAssetDedupeSafety() { assert.ok(!e2e.includes("'objmod search fixture'"), 'generated thumbnail fixtures must not contain fake text model bytes'); } +function testLocalE2eFixturesRemainOptIn() { + const fixtures = fs.readFileSync(path.join(root, 'e2e/local/fixtures.js'), 'utf8'); + // Launching a real VS Code window from a plain `npm test` would be a nasty surprise, so the + // local suite must stay behind an explicit env flag. + assert.ok(fixtures.includes("process.env.WURST_OBJMOD_E2E === '1'"), 'local VS Code e2e must stay opt-in'); + assert.ok(fixtures.includes('test.skip(!enabled'), 'local VS Code e2e must skip, not fail, when disabled'); + // A beforeEach registered inside the shared fixtures module only attaches to whichever spec file + // imported it first, so each spec has to opt in explicitly or it would launch VS Code unasked. + for (const spec of fs.readdirSync(path.join(root, 'e2e/local')).filter((file) => file.endsWith('.spec.js'))) { + const source = fs.readFileSync(path.join(root, 'e2e/local', spec), 'utf8'); + assert.ok( + /^skipUnlessEnabled\(\);$/m.test(source) || /^test\.skip\(!enabled,/m.test(source), + `${spec} must gate itself at top level on the local-e2e env flag`, + ); + } +} + function testObjModEditorTypeAndRecoveryGuards() { const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8'); const webviewFiles = [ @@ -1121,8 +1124,10 @@ async function main() { testTooltipFontRepairsChromiumRejectedGlyphFlags(); testObjModTooltipWidthWiring(); testObjModTooltipPreviewHeaders(); + testObjModSavedAndUsedTooltipColors(); testObjModDensityAndTreeStyling(); testImportedAssetDedupeSafety(); + testLocalE2eFixturesRemainOptIn(); console.log('webview harness tests passed'); } diff --git a/src/features/mapDataPreview.ts b/src/features/mapDataPreview.ts index f9395a2..996363d 100644 --- a/src/features/mapDataPreview.ts +++ b/src/features/mapDataPreview.ts @@ -1203,6 +1203,9 @@ function renderW3iForces(forces: W3iForce[] | undefined): string { const W3I_EDITOR_CSS = ` .field-control:not(:disabled):hover { border-color: var(--vscode-inputOption-activeBorder, var(--vscode-focusBorder, #007fd4)); } .field-control:focus { outline: 1px solid var(--vscode-focusBorder, #007fd4); outline-offset: -1px; } +/* The display below outranks the user-agent's [hidden] rule, so the badge needs its own guard — + without it the "unsaved" marker shows on a clean document too. */ +.dirty-badge[hidden] { display: none; } .dirty-badge { display: inline-block; margin-left: 8px; diff --git a/src/features/objModPreview.ts b/src/features/objModPreview.ts index ba7efcc..382551c 100644 --- a/src/features/objModPreview.ts +++ b/src/features/objModPreview.ts @@ -263,6 +263,21 @@ const openObjModDocuments = new Map(); * `vscode.openWith` opens (or reveals) that file for a cross-reference jump. */ const pendingObjectSelection = new Map(); const OBJMOD_SELECTIONS_STATE_KEY = 'wurst.objModSelectionsByRelativePath.v1'; +const OBJMOD_CUSTOM_COLORS_STATE_KEY = 'wurst.objModCustomColors.v1'; +const OBJMOD_CUSTOM_COLORS_MAX = 12; + +function normalizeObjModCustomColors(values: unknown): string[] { + if (!Array.isArray(values)) return []; + const colors: string[] = []; + for (const value of values) { + const match = /^#?([0-9a-f]{6})$/i.exec(String(value ?? '').trim()); + if (!match) continue; + const hex = match[1].toLowerCase(); + if (!colors.includes(hex)) colors.push(hex); + if (colors.length >= OBJMOD_CUSTOM_COLORS_MAX) break; + } + return colors; +} function objModSelectionPathKey(uri: vscode.Uri): string { const folder = vscode.workspace.getWorkspaceFolder(uri); @@ -1697,6 +1712,7 @@ async function buildHtml( thumbnailWorkerUri?: string, combined?: CombinedObjModInfo, preferredSelectionIdentity?: string, + customColors: string[] = [], ): Promise { const typeLabel = TYPE_LABELS[parsed.ext.slice(1)] ?? parsed.ext.slice(1).toUpperCase(); const triggerStrings = loadTriggerStringsForUri(context.uri); @@ -1719,6 +1735,7 @@ async function buildHtml( selectedKey: pendingKey ?? preferredKey ?? objects[0]?.key ?? '', isPendingJump: !!pendingKey, extended: parsed.extended, + customColors, fileInfo: combined ?? { mainName: fileName }, thumbnailWorkerUri, }) @@ -2188,12 +2205,15 @@ textarea.edit-raw { min-height: 48px; line-height: 1.4; padding: 4px 6px; resize .tt-float-toolbar-row { display: flex; align-items: center; gap: 6px; } .tt-used-colors { display: inline-flex; + flex-wrap: wrap; align-items: center; gap: 3px; + max-width: 123px; padding-left: 6px; margin-left: 2px; border-left: 1px solid color-mix(in srgb, var(--border) 65%, transparent); } +.tt-used-colors[hidden] { display: none; } .cell-edit { display: flex; flex-wrap: wrap; @@ -2597,6 +2617,19 @@ tr.overridden td.field { box-shadow: inset 2px 0 0 color-mix(in srgb, var(--acce box-shadow: 0 2px 8px rgba(0,0,0,.35); } .tt-pop[hidden] { display: none; } +.tt-palette-label { + color: var(--muted); + font-size: 10px; + line-height: 1; +} +.tt-custom-colors { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 2px; + border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} +.tt-custom-colors[hidden] { display: none; } .tt-swatches { display: grid; grid-template-columns: repeat(6, 18px); gap: 4px; } .tt-sw { width: 18px; @@ -3638,11 +3671,15 @@ async function openObjModBackup(uri: vscode.Uri, backupId: string): Promise { private readonly _onDidChange = new vscode.EventEmitter>(); readonly onDidChangeCustomDocument = this._onDidChange.event; + private customColors: string[]; constructor( private readonly extensionUri: vscode.Uri, private readonly workspaceState: vscode.Memento, - ) {} + private readonly globalState: vscode.Memento, + ) { + this.customColors = normalizeObjModCustomColors(globalState.get(OBJMOD_CUSTOM_COLORS_STATE_KEY)); + } async openCustomDocument(uri: vscode.Uri, openContext: vscode.CustomDocumentOpenContext): Promise { if (openContext.backupId) { @@ -3700,6 +3737,7 @@ class ObjModEditorProvider implements vscode.CustomEditorProvider { setModValue(mod, richToWc3(body)); schedule(); }; - const onRawInput = () => { setModValue(mod, rawArea.value); autosizeRaw(rawArea); schedule(); }; + const usedColorGroup = toolbar.querySelector('.tt-used-colors'); + const wireUsedColorSwatches = () => { + if (!usedColorGroup) return; + for (const sw of usedColorGroup.querySelectorAll('.tt-used-sw')) { + sw.addEventListener('mousedown', e => e.preventDefault()); + sw.addEventListener('click', () => { + const hex = sw.getAttribute('data-color'); + if (activeTooltipEdit.rawMode) wrapColor(rawArea, hex); + else applyRichColor(body, hex); + const colorBar = toolbar.querySelector('.tt-bar'); + if (colorBar) updateColorSwatch(colorBar, hex); + }); + } + }; + const refreshUsedColors = value => { + if (!usedColorGroup) return; + usedColorGroup.innerHTML = usedColorSwatchesHtml(value); + usedColorGroup.hidden = !usedColorGroup.childElementCount; + wireUsedColorSwatches(); + positionFloatToolbar(toolbar, box.getBoundingClientRect()); + }; + const onBodyInput = () => { + const value = richToWc3(body); + setModValue(mod, value); + refreshUsedColors(value); + schedule(); + }; + const onRawInput = () => { + setModValue(mod, rawArea.value); + refreshUsedColors(rawArea.value); + autosizeRaw(rawArea); + schedule(); + }; body.addEventListener('input', onBodyInput); body.addEventListener('keydown', onEscapeOrSubmit); rawArea.addEventListener('input', onRawInput); @@ -499,17 +530,9 @@ export function enterTooltipEdit(collapsed, mi, clickEvent) { const bar = toolbar.querySelector('.tt-bar'); if (bar) wireColorBar(bar, body, () => (activeTooltipEdit && activeTooltipEdit.rawMode) ? rawArea : null); - // Quick-reference swatches for colors already used in this tooltip (see tooltipToolbarHtml) — same - // apply logic as a preset swatch, just outside the popover for one-click access. - for (const sw of toolbar.querySelectorAll('.tt-used-sw')) { - sw.addEventListener('mousedown', e => e.preventDefault()); - sw.addEventListener('click', () => { - const hex = sw.getAttribute('data-color'); - if (activeTooltipEdit.rawMode) wrapColor(rawArea, hex); - else applyRichColor(body, hex); - if (bar) updateColorSwatch(bar, hex); - }); - } + // Quick-reference swatches stay live while either the rich or raw editor changes, instead of + // reflecting only the value from when editing started. + wireUsedColorSwatches(); const rawToggle = toolbar.querySelector('.tt-raw-toggle'); if (rawToggle) { diff --git a/src/webview/objModEditor/fieldDisplay.ts b/src/webview/objModEditor/fieldDisplay.ts index 95c6cbb..6ac3cb6 100644 --- a/src/webview/objModEditor/fieldDisplay.ts +++ b/src/webview/objModEditor/fieldDisplay.ts @@ -1,5 +1,5 @@ import { esc, renderWc3Colors } from '../objModWebviewUtils'; -import { ui, vscodeApi } from './state'; +import { initial, ui, vscodeApi } from './state'; export function sourcePill(mod) { if (!mod.source) return ''; @@ -45,6 +45,40 @@ export var PRESET_COLORS = [ ['e55bb0', 'Pink'], ['959697', 'Dark Grey'], ['0042ff', 'Player Blue'], ['fffc01', 'Yellow'], ]; +export const MAX_CUSTOM_COLORS = 12; + +export function normalizeCustomColors(values, max = MAX_CUSTOM_COLORS) { + const colors: string[] = []; + for (const value of Array.isArray(values) ? values : []) { + const match = /^#?([0-9a-f]{6})$/i.exec(String(value || '').trim()); + if (!match) continue; + const hex = match[1].toLowerCase(); + if (!colors.includes(hex) && !PRESET_COLORS.some(preset => preset[0] === hex)) colors.push(hex); + if (colors.length >= max) break; + } + return colors; +} + +export const customColors = normalizeCustomColors(initial.customColors); + +export function rememberCustomColor(value) { + const normalized = normalizeCustomColors([value], 1)[0]; + if (!normalized) return ''; + const previousIndex = customColors.indexOf(normalized); + if (previousIndex === 0) return normalized; + if (previousIndex > 0) customColors.splice(previousIndex, 1); + customColors.unshift(normalized); + if (customColors.length > MAX_CUSTOM_COLORS) customColors.length = MAX_CUSTOM_COLORS; + vscodeApi.postMessage({ type: 'rememberCustomColor', color: normalized }); + return normalized; +} + +export function customSwatchesHtml() { + return customColors.map(hex => + '' + ).join(''); +} + export function swatchesHtml() { return PRESET_COLORS.map(c => '' @@ -56,7 +90,12 @@ export function colorBarHtml(mi) { return '
' + '' + '' + '
'; @@ -65,26 +104,39 @@ export function colorBarHtml(mi) { // Distinct |cffRRGGBB colors already present in a WC3 raw string, in first-seen order — lets the // toolbar offer "colors already used here" for one-click consistency (e.g. reapplying the same gold // used on a keyword elsewhere in the same tooltip) without opening the full preset/custom picker. -export function extractUsedColors(text, max) { - const re = /\|c[0-9a-f]{2}([0-9a-f]{6})/gi; +export function extractUsedColors(text, max = undefined) { + const value = String(text == null ? '' : text); + const limit = Number.isFinite(max) && max > 0 ? Math.floor(max) : Number.POSITIVE_INFINITY; const seen: string[] = []; - let m; - while ((m = re.exec(String(text == null ? '' : text)))) { - const hex = m[1].toLowerCase(); + for (let i = 0; i < value.length; i++) { + if (value[i] !== '|') continue; + const marker = value[i + 1]; + if (marker === '|') { + i++; + continue; + } + if (marker !== 'c' && marker !== 'C') continue; + const argb = value.slice(i + 2, i + 10); + if (!/^[0-9a-f]{8}$/i.test(argb)) continue; + const hex = argb.slice(2).toLowerCase(); if (!seen.includes(hex)) seen.push(hex); - if (seen.length >= (max || 4)) break; + if (seen.length >= limit) break; + i += 9; } return seen; } +export function usedColorSwatchesHtml(v) { + const used = extractUsedColors(v); + return used.map(hex => + '' + ).join(''); +} + function usedColorsHtml(v) { - const used = extractUsedColors(v, 4); - if (!used.length) return ''; - return '' + - used.map(hex => - '' - ).join('') + - ''; + const swatches = usedColorSwatchesHtml(v); + return '' + + swatches + ''; } // Floating toolbar mounted beside a tooltip field while it's being edited in place (see diff --git a/src/webview/objModEditor/richTextEditor.ts b/src/webview/objModEditor/richTextEditor.ts index f2cc02c..8edfec0 100644 --- a/src/webview/objModEditor/richTextEditor.ts +++ b/src/webview/objModEditor/richTextEditor.ts @@ -1,4 +1,5 @@ import { renderWc3Colors } from '../objModWebviewUtils'; +import { customSwatchesHtml, rememberCustomColor } from './fieldDisplay'; // Current selection range for a textarea (kept fresh even after blur, so toolbar/color-picker work). export function taRange(ta) { @@ -303,7 +304,7 @@ export function wireColorBar(bar, rich, getTa) { }); } if (pop) { - for (const sw of pop.querySelectorAll('.tt-sw')) { + const wireSwatch = sw => { sw.addEventListener('mousedown', e => e.preventDefault()); sw.addEventListener('click', () => { const hex = sw.getAttribute('data-color'); @@ -313,9 +314,20 @@ export function wireColorBar(bar, rich, getTa) { updateColorSwatch(bar, hex); pop.hidden = true; }); - } + }; + for (const sw of pop.querySelectorAll('.tt-sw')) wireSwatch(sw); const colorInput = pop.querySelector('.tt-color'); if (colorInput) colorInput.addEventListener('change', () => { + const remembered = rememberCustomColor(colorInput.value); + if (remembered) { + const customGroup = pop.querySelector('.tt-custom-colors'); + const customSwatches = pop.querySelector('.tt-custom-swatches'); + if (customGroup && customSwatches) { + customGroup.hidden = false; + customSwatches.innerHTML = customSwatchesHtml(); + for (const sw of customSwatches.querySelectorAll('.tt-sw')) wireSwatch(sw); + } + } const t = ta(); if (t && useRaw()) wrapColor(t, colorInput.value); else if (rich) applyRichColor(rich, colorInput.value); diff --git a/src/webview/objModEditor/types.ts b/src/webview/objModEditor/types.ts index 2bfb62f..fdab4ba 100644 --- a/src/webview/objModEditor/types.ts +++ b/src/webview/objModEditor/types.ts @@ -54,6 +54,7 @@ export interface ObjModInitial { selectedKey: string; isPendingJump?: boolean; extended: boolean; + customColors?: string[]; fileInfo?: Record; thumbnailWorkerUri?: string; } From 635b24c156c31f79d312f31977c710f3c212e176 Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 17 Aug 2026 19:27:31 +0200 Subject: [PATCH 2/2] Address review: density-scale the saved palette, build bundles for e2e UI mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The saved-colour group in the tooltip colour popover kept hard-coded compact `gap`/`padding-top`, so it stayed on the compact scale while the rest of the editor switched to cozy. Both numbers now live in the :root / body.density-cozy variable pair as --palette-group-gap / --palette-group-pad, per AGENTS.md. Covered by a new spec that measures the resolved spacing in both densities; verified it fails against the hard-coded values it replaces. `test:e2e:ui` now runs compile-web first, like `test:e2e` already did — on a fresh checkout it otherwise opened Playwright's UI against a missing dist/webview bundle and every objmod test failed on the harness guard. --- e2e/specs/objmod-tooltip-editor.spec.js | 30 +++++++++++++++++++++++++ package.json | 2 +- src/features/objModPreview.ts | 10 +++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/e2e/specs/objmod-tooltip-editor.spec.js b/e2e/specs/objmod-tooltip-editor.spec.js index d8b2432..9843210 100644 --- a/e2e/specs/objmod-tooltip-editor.spec.js +++ b/e2e/specs/objmod-tooltip-editor.spec.js @@ -180,6 +180,36 @@ test('saved custom colours come back in the palette after a reload', async ({ op await expect(custom.locator('.tt-custom-sw').nth(0)).toHaveAttribute('data-color', '123456'); }); +test('the saved-palette spacing follows the density scale', async ({ openObjMod }) => { + const { page, host, gotoHtml } = await openObjMod({ + globalState: { [CUSTOM_COLORS_KEY]: ['123456'] }, + }); + await gotoHtml(host.html); + + // Clicking the density toggle is a click outside the editor, which closes it and takes the + // floating toolbar with it — so each density is measured on a freshly opened palette. + const measurePalette = async () => { + const { toolbar } = await openTooltipEditor(page); + await toolbar.locator('.tt-color-sq').click(); + const spacing = await toolbar.locator('.tt-pop .tt-custom-colors').evaluate((el) => { + const style = getComputedStyle(el); + return { gap: parseFloat(style.rowGap), pad: parseFloat(style.paddingTop) }; + }); + await page.keyboard.press('Escape'); + return spacing; + }; + + const compact = await measurePalette(); + await page.click('#density-toggle'); + await expect(page.locator('body')).toHaveClass(/density-cozy/); + + // Hard-coded px here would leave this corner of the toolbar on the compact scale while the rest + // of the editor switched — the whole point of the :root / body.density-cozy variable pair. + const cozy = await measurePalette(); + expect(cozy.gap).toBeGreaterThan(compact.gap); + expect(cozy.pad).toBeGreaterThan(compact.pad); +}); + test('a preset colour is never duplicated into the saved palette', async ({ openObjMod }) => { const { page, host, gotoHtml } = await openObjMod({ // ffcc00 is the "Gold" preset; only the non-preset colour should be kept. diff --git a/package.json b/package.json index 71b8499..3d7ce8e 100644 --- a/package.json +++ b/package.json @@ -885,7 +885,7 @@ "watch-web": "webpack --watch", "test": "npm run test:fuzzy && npm run test:image-decoders && npm run test:diagnostics && npm run test:webview", "test:e2e": "npm run compile-web && playwright test --project=webview", - "test:e2e:ui": "playwright test --project=webview --ui", + "test:e2e:ui": "npm run compile-web && playwright test --project=webview --ui", "test:image-decoders": "node ./scripts/test-image-decoders.js", "test:diagnostics": "node ./scripts/test-diagnostics.js", "test:vsix-contents": "node ./scripts/test-vsix-contents.js", diff --git a/src/features/objModPreview.ts b/src/features/objModPreview.ts index 382551c..f453a24 100644 --- a/src/features/objModPreview.ts +++ b/src/features/objModPreview.ts @@ -1844,6 +1844,10 @@ async function buildHtml( /* Row height for the field table's collapsed/edit cells, so toggling a cell into edit mode never resizes its row. */ --cell-h: 20px; + /* Saved-colour group inside the tooltip colour popover: the gap between its label and swatch grid, + and the breathing room above the rule separating it from the presets. */ + --palette-group-gap: 4px; + --palette-group-pad: 2px; } body.density-cozy { --pad-x: 16px; @@ -1869,6 +1873,8 @@ body.density-cozy { --cat-pad: 5px 8px 4px; --table-header-h: 27px; --cell-h: 24px; + --palette-group-gap: 6px; + --palette-group-pad: 4px; } .content { flex: 1; @@ -2625,8 +2631,8 @@ tr.overridden td.field { box-shadow: inset 2px 0 0 color-mix(in srgb, var(--acce .tt-custom-colors { display: flex; flex-direction: column; - gap: 4px; - padding-top: 2px; + gap: var(--palette-group-gap); + padding-top: var(--palette-group-pad); border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); } .tt-custom-colors[hidden] { display: none; }