diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml
new file mode 100644
index 000000000..94191ac4c
--- /dev/null
+++ b/.github/workflows/harness.yml
@@ -0,0 +1,64 @@
+name: "Harness"
+
+# Render/drive the binary-editor and dialog-editor webview harnesses in headless Chromium as a regression
+# suite (scripts/test-harness.sh). Each driver mounts the real webview bundle and asserts through PASS/FAIL
+# gates - catching render/mount/CSP/layout regressions the node/jsdom vitest suites cannot see.
+#
+# Kept in its OWN job rather than the main "Build" gate so the Chromium download (~150MB) and the browser
+# runs add no wall-clock to every other check. See the harness READMEs and docs/architecture.md.
+
+on:
+ pull_request:
+ push:
+ branches:
+ - "**"
+
+# Cancel superseded runs on the same ref (branch pushes and PR updates).
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ harness:
+ name: harness
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+ with:
+ run_install: false
+
+ - name: Install Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ # The dialog-editor edit drivers parse real .d through the weidu-d tree-sitter WASM (via the fake host),
+ # which is a gitignored build artifact.
+ - name: Build tree-sitter grammar
+ run: pnpm build:grammar
+
+ # playwright is a pinned devDep, but pnpm's build-script gate skips its browser postinstall, so pull the
+ # one browser the harnesses launch here. --with-deps installs the OS libraries Chromium needs on the runner.
+ - name: Install Chromium for Playwright
+ run: pnpm exec playwright install --with-deps chromium
+
+ # Several binary-editor drivers read real .itm/.cre/.map files from the gitignored-but-reproducible
+ # external/ trees (the dialog drivers and the deep-jump driver use committed testFixture/ and need none).
+ - name: Reset external fixture repos
+ run: ./scripts/reset-external.sh
+
+ - name: Run render/drive harnesses
+ run: ./scripts/test-harness.sh
diff --git a/.gitignore b/.gitignore
index b21cbaa58..a9acfcf5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ bgforge-mls-kate*/
.reports/
.vscode-test/
.dev/
+.playwright-mcp/
client/node_modules
client/out
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index d2283fd6a..9d29e61c5 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -32,6 +32,8 @@
"themes/vs-seti-icon-theme.json",
"themes/bgforge-monokai.json",
"themes/bgforge-icon-theme.json",
- "binary-editor/test/harness/*.html"
+ "binary-editor/test/harness/*.html",
+ "client/src/dialog-editor/test/harness/app.html",
+ "client/src/dialog-editor/test/harness/real-model.ts"
]
}
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 612826754..b7d166615 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -266,11 +266,23 @@
// `document.getElementById` is clearer and faster than
// `.querySelector('#id')`; the rule's premise doesn't hold.
// Webview scripts are the only DOM consumers in the codebase.
- "files": ["client/src/**/*-webview*.ts"],
+ "files": ["client/src/**/*-webview*.ts", "client/src/dialog-editor/webview/**"],
"rules": {
"unicorn/prefer-query-selector": "off"
}
},
+ {
+ // Svelte 5 runes require `let`: `let x = $state(...)` and
+ // `let { ... } = $props()` are reassigned by the compiler's reactivity,
+ // which oxlint cannot see - it reports them as never-reassigned. `sort-vars`
+ // likewise fights the rune/prop declaration order. Scope: the dialog editor's
+ // Svelte webview components.
+ "files": ["client/src/dialog-editor/webview/**/*.svelte"],
+ "rules": {
+ "prefer-const": "off",
+ "sort-vars": "off"
+ }
+ },
{
// VS Code's `webview.postMessage()` (extension side),
// `acquireVsCodeApi().postMessage()` (webview side), and
@@ -278,9 +290,9 @@
// `window.postMessage`; none take a `targetOrigin`.
"files": [
"client/src/editors/**",
- "client/src/dialog-tree/**",
"client/src/webview-utils.ts",
- "client/src/binary-editor/**"
+ "client/src/binary-editor/**",
+ "client/src/dialog-editor/**"
],
"rules": {
"unicorn/require-post-message-target-origin": "off"
@@ -366,6 +378,7 @@
"**/out/**",
"**/*.d.ts",
"server/src/user-messages.ts",
- "binary-editor/test/harness/**"
+ "binary-editor/test/harness/**",
+ "client/src/dialog-editor/test/harness/**"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index 968302c38..606e485fe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,7 +18,7 @@ Dependabot version-update PRs are intentionally disabled - there is no `.github/
- **User-facing messages:** Never call `connection.window.showInformationMessage/showWarningMessage/showErrorMessage` directly in server code. Use `showInfo()`, `showWarning()`, `showError()`, or `showErrorWithActions()` from `user-messages.ts` - they auto-decode `file://` URIs to readable paths. An oxlint rule enforces this.
- **Temporary artifacts:** Put transient test/build files under the repo-level `tmp/` directory (or `os.tmpdir()` when system temp is required). Do not create ad hoc temp directories under source trees like `server/test/`, `binary/test/`, or `scripts/**`.
- **Webview CSP - styles need `cspSource`, not a bare nonce.** A webview's `style-src` must include `{{cspSource}}`. VS Code's wrapped webview silently drops a `style-src 'nonce-...'` stylesheet that lacks `cspSource` (raw Chromium honors it, so headless/standalone renders pass while the real panel renders fully unstyled). Load CSS as `webview.asWebviewUri()` `` elements with `style-src {{cspSource}}` (keep the nonce for `script-src` only); add each CSS dir to `localResourceRoots`. See `docs/architecture.md` (Webview CSP); guarded by `client/test/webview-csp.test.ts`.
-- **Binary-editor webview changes can be rendered - don't fall back to a sketch without checking.** A headless Playwright harness in `binary-editor/test/harness/` loads the real `App.svelte` bundle in Chromium and writes per-format PNG screenshots (e.g. `render-pro-eff.mts` -> `shot-pro.png` + `shot-eff.png`, `render-itm.mts`, `render-spl.mts`, `render-cre.mts`, `render-map.mts`, `render-primitives.mts`). For any visual/CSS/layout change to the binary editor, render and inspect the screenshot rather than reasoning about the cascade blind. Run order: `cd binary && pnpm build` (only if `binary/src` changed), then `pnpm exec tsx binary-editor/test/harness/build.mts` (rebuilds `app.html` after any webview/Svelte/`styles.css` edit), then a driver `pnpm exec tsx binary-editor/test/harness/render-pro-eff.mts`. Prereqs (environment, not repo deps): Playwright + a Chromium browser on `PATH`. It is e2e-tier and intentionally excluded from `pnpm test`/`pnpm test:all`. See `binary-editor/test/harness/README.md` for the harness, and **`docs/binary-editor-ui-guidelines.md`** for the project's UI conventions and the review brief - it indexes the co-located writer-facing rules (`client/src/binary-editor/webview/AGENTS.md` for the render layer, `binary/src/AGENTS.md` for the layout schema), which auto-load when you edit those dirs. Review rendered screenshots against the guidelines.
+- **Binary-editor webview changes can be rendered - don't fall back to a sketch without checking.** A headless Playwright harness in `binary-editor/test/harness/` loads the real `App.svelte` bundle in Chromium and writes per-format PNG screenshots (e.g. `render-pro-eff.mts` -> `shot-pro.png` + `shot-eff.png`, `render-itm.mts`, `render-spl.mts`, `render-cre.mts`, `render-map.mts`, `render-primitives.mts`). For any visual/CSS/layout change to the binary editor, render and inspect the screenshot rather than reasoning about the cascade blind. Run order: `cd binary && pnpm build` (only if `binary/src` changed), then `pnpm exec tsx binary-editor/test/harness/build.mts` (rebuilds `app.html` after any webview/Svelte/`styles.css` edit), then a driver `pnpm exec tsx binary-editor/test/harness/render-pro-eff.mts`. `playwright` is a pinned devDep; the Chromium browser it drives is the environment prereq (`pnpm exec playwright install chromium`). The drivers assert through PASS/FAIL gates and now run in CI as a regression suite via the separate `Harness` workflow (`scripts/test-harness.sh` - both editors' harnesses); they stay excluded from `pnpm test`/`pnpm test:all`, which run browserless. See `binary-editor/test/harness/README.md` for the harness, and **`docs/binary-editor-ui-guidelines.md`** for the project's UI conventions and the review brief - it indexes the co-located writer-facing rules (`client/src/binary-editor/webview/AGENTS.md` for the render layer, `binary/src/AGENTS.md` for the layout schema), which auto-load when you edit those dirs. Review rendered screenshots against the guidelines.
- **To run the whole extension in a real VS Code instance, use `pnpm dev:web`.** It starts [code-server](https://github.com/coder/code-server) (VS Code in a browser) with this repo loaded as an unpacked extension, so the LSP server and binary custom editor actually run - the screenshot harness above only renders the webview in isolation. Default serves plain HTTP on `0.0.0.0:8080` (override with `CODE_SERVER_PORT`/`CODE_SERVER_HOST`); it bootstraps a pinned code-server into the gitignored `.dev/` on first run, then builds via `build:dev`. The command runs a long-lived server in the foreground - background it (or run it as a separate task) if you need the shell, bind to the port your environment exposes, and confirm it is up before reporting a URL. The binary editor is a webview, so the browser must reach it over a secure context (`http://localhost` via a port-forward, or a trusted cert) or the editor renders blank. Full details and configuration: **`scripts/dev-web.md`**.
- **Use `pnpm` exclusively. Never use `npx`.** This is a hard requirement. Every time you reach for `npx`, `npm`, or any npm-series command, stop and use `pnpm exec` instead. Example: `pnpm exec playwright` not `npx playwright`. This applies to all contexts including one-off commands, scripts, subagents, and delegated tasks. If pnpm is not available in a context, install it first or use the workspace package.json scripts.
- **Milestone close-out commands (scope to what you touched).** Binary changes -> `pnpm exec vitest run --config binary/vitest.config.ts` (run from the repo root, not `cd binary` - several tests resolve `external/` and `client/testFixture/` fixtures relative to cwd); webview/Svelte changes -> the client tests plus the binary-editor render harness. Reserve `pnpm build:all` + `pnpm test:all` (~9 min) for changes that span subsystems or touch shared build infra, grammars, transpilers, or the server.
@@ -120,6 +120,8 @@ cd grammars/weidu-tp2 && pnpm test # or any grammars/*/
```
+**Testing against real external files.** The `external/` mod trees are gitignored but REPRODUCIBLE - `pnpm test:external` (via `scripts/reset-external.sh` + `scripts/external-repos-lib.sh`) clones/checks them out at pinned refs. So real-corpus coverage belongs in a committed test, not a throwaway: tests that exercise real external files live under `server/test/integration/**` (run by `pnpm test:integration`, config `server/vitest.integration.config.ts`), using `test/integration/test-helpers.ts` (`FALLOUT_FIXTURES` = `external/fallout`, `IE_FIXTURES` = `external/infinity-engine`, `loadFixture`/`loadFixtures`). Gate a corpus sweep with `describe.skipIf(files.length === 0)` so it skips cleanly when the corpus is not checked out. Before placing any real-file test, read a sibling there (`integration/weidu-d.test.ts`, `fallout-ssl/rename.test.ts`) for the fixture/init conventions. Do NOT commit copies of gitignored `external/` files as fixtures, and do NOT hand-run a one-off script where this suite is the home.
+
## Publishing & Release
Releases are tag-driven via GitHub Actions. `docs/releasing.md` is the canonical reference: the tag scheme (which tag form triggers which workflow), and the per-stream release procedures for the extension, the three libraries, and the reusable Action - including the root/`server` version-identity invariant. The server and VSIX bundle their `@bgforge/*` libraries rather than depending on them at runtime, so the extension and the libraries release in any order. Consult it before cutting any release. See `docs/architecture.md` for packaging mechanics.
@@ -132,6 +134,7 @@ Releases are tag-driven via GitHub Actions. `docs/releasing.md` is the canonical
- `ts-morph` is pinned to `^27.0.2` across the server runtime and all transpiler subpackages so the bundled TypeScript matches the project's own `typescript ^5.9.3` pin. ts-morph bundles its TypeScript dependency rather than treating it as a peer, so the ts-morph version effectively chooses the TS compiler that runs against transpiler ASTs regardless of the workspace `typescript` pin. ts-morph 28 bundles TS 6.0 - TS 6 is on a 6.0.x line with no 6.1 published, fails the standard "matured target major" upgrade rule, and ts-morph 28 is therefore deferred until TS 6.1 ships. ts-morph 27.0.x is technically also on a 0-minor line, but the TS 5.9 alignment is the deciding factor. Bump in lockstep with the project's `typescript` pin: when `typescript` moves to 6.x, ts-morph moves to whichever ts-morph major bundles that TS line.
- `@types/node` tracks the latest LTS Node major, currently `^24.x`. The extension targets LTS Node only (minimum supported is 20), so do not bump `@types/node` to odd-numbered "Current" majors (e.g. 25.x) - that would expose type definitions for APIs not present at the supported runtime floor. Move it forward only when a new even-numbered Node release reaches LTS.
- `ini` (runtime dep of `@bgforge/format`) is held at `^6.x`; `7.0.0` is a major with potential parse/stringify behavior changes that need a changelog review before adoption.
+- `playwright` (devDep) is pinned to an EXACT version (no caret) because the webview harnesses launch a browser from Playwright's version-keyed cache: the devbox and the `Harness` CI job download that browser via `playwright install`, and a caret drift to a version whose browser revision is not cached would break the harness run until a re-download. Bump the pin and re-run `playwright install` together. Its browser postinstall is skipped by pnpm's build-script gate (not in `onlyBuiltDependencies`), so a plain `pnpm install` never pulls ~150MB of browser - the harness paths install Chromium explicitly.
- `sslc-emscripten-noderawfs` (the built-in SSL compiler WASM, `server/package.json`) is an HTTPS GitHub-release tarball, and its `pnpm-lock.yaml` entry carries a hand-maintained `integrity` field. pnpm 11.4+ fails closed (`ERR_PNPM_MISSING_TARBALL_INTEGRITY`) on any tarball lockfile entry that lacks integrity, and URL-tarball resolvers only learn the hash on download - so when the package is reused from the store, the field is never emitted. Practical rule: **evolve the lockfile incrementally** (`pnpm install` / `pnpm update`); pnpm 11.5.0+ preserves the existing integrity across those, so `pnpm update` keeps working. Do **not** `rm pnpm-lock.yaml` to regenerate from scratch - that drops the field and breaks `pnpm update` until it is re-added. The release asset is immutable, so the hash is stable; if a full regen is ever unavoidable, restore the line `resolution: {integrity: sha512-oQEOCRLmCodC1MlcIVAqGyTb3aWLpJ7c9tQm4CF8hrf9feZoxcIvSfwKpkcq0FykigqVGdpFpkbwQBS8yqo28Q==, tarball: }`. The same dep is also listed under `minimumReleaseAgeExclude` in `pnpm-workspace.yaml` (a non-registry tarball has no publish timestamp for the 24h maturity window to check).
## Architecture
diff --git a/binary-editor/test/harness/README.md b/binary-editor/test/harness/README.md
index 7d45f8dd5..c38f31120 100644
--- a/binary-editor/test/harness/README.md
+++ b/binary-editor/test/harness/README.md
@@ -74,17 +74,14 @@ It is type-checked via `test/harness/tsconfig.json`, which includes the DOM lib
## Prerequisites
-- **Playwright + a Chromium browser** must be available on `PATH` in whatever environment runs the harness.
- Install Playwright and its browsers globally:
+- **Playwright** is a pinned devDependency (`pnpm install` provides it), so `pnpm exec tsx ` resolves
+ `import { chromium } from "playwright"` with no global install. Its browser postinstall is skipped by pnpm's
+ build-script gate, so install the one browser the drivers launch:
```
- npm install -g playwright
- playwright install chromium
+ pnpm exec playwright install chromium
```
- Playwright is intentionally NOT listed in any `package.json` in this repo. It is an environment
- prerequisite, not a repo dependency.
-
- **Node 20+** (matched to the project's minimum supported runtime).
- **`tsx`** - available via the repo's dev dependencies (`pnpm exec tsx ...`).
diff --git a/binary-editor/test/harness/out-dir.ts b/binary-editor/test/harness/out-dir.ts
new file mode 100644
index 000000000..985de2b56
--- /dev/null
+++ b/binary-editor/test/harness/out-dir.ts
@@ -0,0 +1,14 @@
+import { mkdirSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// Repo-level tmp/ for harness screenshots, keeping the source tree clean (project convention).
+// This module lives in binary-editor/test/harness/, so the repo root is three levels up.
+const here = path.dirname(fileURLToPath(import.meta.url));
+export const SHOT_DIR = path.resolve(here, "../../../tmp");
+mkdirSync(SHOT_DIR, { recursive: true });
+
+/** Absolute path for a harness screenshot, under the repo tmp/ dir. */
+export function shotPath(name: string): string {
+ return path.join(SHOT_DIR, name);
+}
diff --git a/binary-editor/test/harness/render-clip-sweep.mts b/binary-editor/test/harness/render-clip-sweep.mts
index decc075e9..ed2fc03bb 100644
--- a/binary-editor/test/harness/render-clip-sweep.mts
+++ b/binary-editor/test/harness/render-clip-sweep.mts
@@ -99,7 +99,6 @@ async function runFormat(browser: Browser, label: string, uri: string, bytes: Ui
});
await page.goto("file://" + path.join(here, "app.html"));
await page.waitForSelector(".layout-root", { timeout: 5000 });
- await page.waitForTimeout(200);
const found: ClipViolation[] = [];
// Check whatever a freshly-opened detail/list selection renders too: select the first list row if one is
@@ -109,7 +108,11 @@ async function runFormat(browser: Browser, label: string, uri: string, bytes: Ui
const firstRow = page.locator(".master .vlist .vrow").first();
if (await firstRow.count()) {
await firstRow.click().catch(() => undefined);
- await page.waitForTimeout(150);
+ await page
+ .waitForFunction(() => document.querySelector(".detail .layout-root .field") !== null, undefined, {
+ timeout: 5000,
+ })
+ .catch(() => undefined);
found.push(...(await collectClipViolations(page, ctx + " (detail)")));
}
};
@@ -123,7 +126,18 @@ async function runFormat(browser: Browser, label: string, uri: string, bytes: Ui
const tab = tabs.nth(i);
const name = ((await tab.textContent()) ?? `tab${i}`).replace(/\s*\(\d.*\)\s*$/, "").trim();
await tab.click();
- await page.waitForTimeout(200);
+ await page
+ .waitForFunction(
+ (expectedLabel) => {
+ const active = document.querySelector(
+ '.bb-tabs.primary button[role="tab"][aria-selected="true"]',
+ );
+ return !!active && (active.textContent ?? "").includes(expectedLabel);
+ },
+ name,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
await sweepCurrentView(`${label} > ${name}`);
}
}
diff --git a/binary-editor/test/harness/render-cre-v1.mts b/binary-editor/test/harness/render-cre-v1.mts
index 62d14c1e5..23e8e1d02 100644
--- a/binary-editor/test/harness/render-cre-v1.mts
+++ b/binary-editor/test/harness/render-cre-v1.mts
@@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
import { creParser } from "../../../binary/src/cre/index";
import { getCreCanonicalDocument, rebuildCreCanonicalDocument } from "../../../binary/src/cre/canonical-reader";
import { serializeCreCanonicalDocument } from "../../../binary/src/cre/canonical-writer";
@@ -98,17 +99,19 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => {
});
await page.goto("file://" + path.join(here, "app.html"));
await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 });
-await page.waitForTimeout(200);
async function clickTab(label: string): Promise {
await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: label }).first().click();
- await page.waitForTimeout(200);
+ await page
+ .locator('.bb-tabs.primary button[role="tab"][aria-selected="true"]')
+ .filter({ hasText: label })
+ .first()
+ .waitFor({ timeout: 5000 });
}
const effectsPanel = page.locator(".panel").filter({ has: page.locator("h3", { hasText: /^Effects$/ }) });
async function selectRow(scope: Locator, idx: number): Promise {
await scope.locator(".vlist .vrow").nth(idx).click();
await scope.locator(".row-actions").first().waitFor({ timeout: 3000 });
- await page.waitForTimeout(100);
}
await clickTab("Effects");
@@ -142,7 +145,7 @@ check(
const opcodeCombobox = await effectsPanel.locator(".detail .bb-combobox-input").count();
check("v1: opcode detail field is a searchable combobox", opcodeCombobox >= 1, `count=${opcodeCombobox}`);
-await page.screenshot({ path: path.join(here, "shot-cre-effects-v1.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-cre-effects-v1.png"), fullPage: true });
await browser.close();
diff --git a/binary-editor/test/harness/render-cre.mts b/binary-editor/test/harness/render-cre.mts
index 8f415654b..f86b69cbc 100644
--- a/binary-editor/test/harness/render-cre.mts
+++ b/binary-editor/test/harness/render-cre.mts
@@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
import { creParser } from "../../../binary/src/cre/index";
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -100,6 +101,8 @@ async function doUndo(): Promise {
const r = dispatch({ type: "undo", sessionId });
if (r.type === "structure") postToWebview({ type: "changeSet", changeSet: r.result.changeSet });
else postToWebview({ type: "invalidated" });
+ // The refresh touches several independent DOM regions (fields, tab badges, lists) with no single
+ // DOM-observable completion signal generic across every call site - bounded settle, not a condition poll.
await activePage?.waitForTimeout(150);
}
@@ -119,10 +122,10 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => {
});
await page.goto("file://" + path.join(here, "app.html"));
await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 });
-await page.waitForTimeout(200);
+await page.waitForSelector(".layout-root .panel h3", { timeout: 5000 });
// CRE is tabbed; capture the default (General) tab immediately so the screenshot exists regardless of
// the later structure-op steps (which navigate into the Spells / Effects tabs).
-await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-cre.png"), fullPage: true });
// ---- Dropdown width guard (binary-editor UI guidelines: dropdowns are sized to their OWN longest option on a
// dedicated dd-{1..5} ch scale, decoupled from the text-input tiers - so a dropdown sharing a column with a
@@ -140,7 +143,9 @@ await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true })
alignClass,
);
await alignFc.locator(".bb-combobox-input").click(); // focusing the input opens the list (chevron is decorative)
- await page.waitForTimeout(150);
+ await page
+ .waitForFunction(() => document.querySelectorAll(".bb-popup-item").length > 0, undefined, { timeout: 5000 })
+ .catch(() => undefined);
// The OPEN list must render real items: a visible row height (a collapsed ~6px row clips the label) and a
// non-empty label on every option, with an item highlighted (on open it is the current value's row; after a
// filter bits-ui highlights the first match - covered by the primitives probe).
@@ -159,7 +164,16 @@ await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true })
JSON.stringify(listInfo),
);
await page.locator(".bb-popup-item", { hasText: "Chaotic neutral" }).first().click();
- await page.waitForTimeout(150);
+ await page
+ .waitForFunction(
+ () => {
+ const el = document.querySelector('.bb-combobox-input[aria-label="Alignment"]');
+ return !!el && (el as HTMLInputElement).value.includes("Chaotic neutral");
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const alignClip = await alignFc
.locator(".bb-combobox-input")
.evaluate((el: HTMLInputElement) => ({ text: el.value, clipped: el.scrollWidth > el.clientWidth + 1 }));
@@ -171,9 +185,21 @@ await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true })
// Re-picking the CURRENT value must keep it and close: bits-ui's single-select toggles the selection OFF on a
// re-pick (value -> ""), which would blank an enum and leave the list open. "Chaotic neutral" is selected now.
await alignFc.locator(".bb-combobox-input").click();
- await page.waitForTimeout(120);
await page.locator(".bb-popup-item", { hasText: "Chaotic neutral" }).first().click();
- await page.waitForTimeout(120);
+ await page
+ .waitForFunction(
+ () => {
+ const el = document.querySelector('.bb-combobox-input[aria-label="Alignment"]');
+ return (
+ !!el &&
+ (el as HTMLInputElement).value.includes("Chaotic neutral") &&
+ document.querySelectorAll(".bb-combobox-content").length === 0
+ );
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const rePick = await alignFc.locator(".bb-combobox-input").evaluate((el: HTMLInputElement) => el.value);
const rePickOpen = await page.locator(".bb-combobox-content").count();
check(
@@ -185,7 +211,11 @@ await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true })
async function clickTab(label: string): Promise {
await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: label }).first().click();
- await page.waitForTimeout(200);
+ await page
+ .locator('.bb-tabs.primary button[role="tab"][aria-selected="true"]')
+ .filter({ hasText: label })
+ .first()
+ .waitFor({ timeout: 5000 });
}
const effectsPanel = page.locator(".panel").filter({ has: page.locator("h3", { hasText: /^Effects$/ }) });
@@ -193,11 +223,26 @@ const effectsPanel = page.locator(".panel").filter({ has: page.locator("h3", { h
async function selectRow(scope: Locator, idx: number): Promise {
await scope.locator(".vlist .vrow").nth(idx).click();
await scope.locator(".row-actions").first().waitFor({ timeout: 3000 });
- await page.waitForTimeout(100);
}
+// Effects RowActions (Add above/Duplicate/Delete) drive a real structureOp round trip (webview -> host -> dispatch
+// -> changeSet reply), so the row count is the settle signal - callers always check it via a Node-side
+// sectionCount() that only reflects the mutation once the reply has landed. clickAction/clickDelete are only ever
+// called against the Effects panel in this file, so the row list is queried directly rather than threaded through.
async function clickAction(scope: Locator, ariaLabel: string): Promise {
+ const before = await scope.locator(".vlist .vrow").count();
await scope.locator(`.row-actions button[aria-label="${ariaLabel}"]`).first().click();
- await page.waitForTimeout(200);
+ await page
+ .waitForFunction(
+ (b) => {
+ const panel = Array.from(document.querySelectorAll(".panel")).find(
+ (p) => (p.querySelector("h3")?.textContent ?? "").trim() === "Effects",
+ );
+ return !!panel && panel.querySelectorAll(".vlist .vrow").length !== b;
+ },
+ before,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
}
async function clickDelete(scope: Locator): Promise {
// Delete fires immediately (no confirm step) - a single click on the Delete button removes the entry.
@@ -357,7 +402,7 @@ const memorizedTotal = (): number => {
// ============================================================
await clickTab("Spells");
await page.waitForSelector(".spellbook", { timeout: 3000 });
-await page.screenshot({ path: path.join(here, "shot-cre-spells.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-cre-spells.png"), fullPage: true });
const spellbookTypeTabs = await page.locator(".spellbook .bb-tabs button[role='tab']").allInnerTexts();
check(
"spells: spellbook renders a spell-type subtab (Wizard for the mage fixture)",
@@ -375,7 +420,17 @@ const readSpellsTab = () =>
});
const spellsTabBefore = await readSpellsTab();
await firstLevelCard.locator("button.sb-add", { hasText: "memorize" }).first().click();
-await page.waitForTimeout(250);
+await page
+ .waitForFunction(
+ (before) => {
+ const tabs = Array.from(document.querySelectorAll('.bb-tabs.primary button[role="tab"]'));
+ const tab = tabs.find((b) => (b.textContent ?? "").trim().startsWith("Spells"));
+ return !!tab && (tab.textContent ?? "").trim() !== before;
+ },
+ spellsTabBefore,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const spellsTabAfter = await readSpellsTab();
check(
"spells: + memorize adds a memorized spell (count +1)",
@@ -403,13 +458,11 @@ check(
// ---- Spellbook card layout: cards must be a uniform width (no flex-grow drift where a trailing odd card on a
// partial row hits max-width and is wider), and a level's Known and Memorized entries must align row-for-row. ----
await page.setViewportSize({ width: 900, height: 1500 });
-await page.waitForTimeout(150);
const cardWidths = await page.evaluate(() =>
Array.from(document.querySelectorAll(".spellbook .sb-level"), (el) => Math.round(el.getBoundingClientRect().width)),
);
check("spells: all level cards have equal width", new Set(cardWidths).size === 1, JSON.stringify(cardWidths));
await page.setViewportSize({ width: 1280, height: 1000 });
-await page.waitForTimeout(150);
const entryAlign = await page.evaluate(() => {
for (const c of Array.from(document.querySelectorAll(".spellbook .sb-level"))) {
const cols = c.querySelectorAll(".sb-col");
@@ -545,7 +598,7 @@ check(
await clickTab("Effects");
await selectRow(effectsPanel, 0);
await effectsPanel.locator(".detail .layout-root .field").first().waitFor({ timeout: 3000 });
-await page.screenshot({ path: path.join(here, "shot-cre-effects.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-cre-effects.png"), fullPage: true });
await browser.close();
diff --git a/binary-editor/test/harness/render-itm.mts b/binary-editor/test/harness/render-itm.mts
index d87298621..c46d81f50 100644
--- a/binary-editor/test/harness/render-itm.mts
+++ b/binary-editor/test/harness/render-itm.mts
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
import { itmParser } from "../../../binary/src/itm/index";
import { getItmCanonicalDocument, rebuildItmCanonicalDocument } from "../../../binary/src/itm/canonical-reader";
import { serializeItmCanonicalDocument } from "../../../binary/src/itm/canonical-writer";
@@ -118,18 +119,25 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => {
});
await page.goto("file://" + path.join(here, "app.html"));
await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 });
-await page.waitForTimeout(200);
+await page.waitForSelector(".layout-root .panel h3", { timeout: 5000 });
// Default tab is General; capture it, then navigate to the tree tab below.
-await page.screenshot({ path: path.join(here, "shot-itm.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-itm.png"), fullPage: true });
async function clickTab(label: string): Promise {
await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: label }).first().click();
- await page.waitForTimeout(200);
+ await page
+ .locator('.bb-tabs.primary button[role="tab"][aria-selected="true"]')
+ .filter({ hasText: label })
+ .first()
+ .waitFor({ timeout: 5000 });
}
// Undo a structure op and refresh the webview (the host pushes the op onto the undo stack; "invalidated"
// makes the tree re-fetch), so each op test runs from the same baseline.
async function doUndo(): Promise {
dispatch({ type: "undo", sessionId });
await page.evaluate((rr) => window.postMessage(rr, "*"), { type: "invalidated" } as HostToWebview);
+ // The invalidation triggers an async version-bump -> re-fetch round trip inside the webview with no single
+ // DOM-observable completion signal generic across every call site (dispatch-level checks, tree re-renders,
+ // and RowActions targets all key off it differently) - bounded settle, not a condition poll.
await page.waitForTimeout(200);
}
@@ -227,7 +235,19 @@ const boxCounts = async (): Promise<{ total: number; checked: number }> => ({
checked: await unusablePanel.locator('[role="checkbox"][aria-checked="true"]').count(),
});
await unusablePanel.getByRole("button", { name: "Select all", exact: true }).click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(
+ () => {
+ const panel = Array.from(document.querySelectorAll(".panel")).find(
+ (p) => p.querySelector("h3")?.textContent === "Unusable By",
+ );
+ const boxes = panel?.querySelectorAll('[role="checkbox"]') ?? [];
+ return boxes.length > 0 && Array.from(boxes).every((b) => b.getAttribute("aria-checked") === "true");
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const afterSelect = await boxCounts();
check(
"bulk: Select all checks every flag in the panel",
@@ -235,7 +255,19 @@ check(
`${afterSelect.checked}/${afterSelect.total}`,
);
await unusablePanel.getByRole("button", { name: "Deselect all", exact: true }).click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(
+ () => {
+ const panel = Array.from(document.querySelectorAll(".panel")).find(
+ (p) => p.querySelector("h3")?.textContent === "Unusable By",
+ );
+ const boxes = panel?.querySelectorAll('[role="checkbox"]') ?? [];
+ return boxes.length > 0 && Array.from(boxes).every((b) => b.getAttribute("aria-checked") !== "true");
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
check("bulk: Deselect all clears every flag in the panel", (await boxCounts()).checked === 0, "");
// ============================================================
@@ -249,7 +281,16 @@ check("baseline: 3 effects", sectionKids(effectsNodeId).total === 3, `total=${se
// ============================================================
await clickTab("Abilities & Effects");
await page.waitForSelector(".eff-tree .eff-tree-vrow", { timeout: 5000 });
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(
+ () => {
+ const tab = document.querySelector('.bb-tabs.primary button[role="tab"][aria-selected="true"]');
+ return !!tab && (tab.textContent ?? "").includes("2/3");
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const treeTabText = (
(await page.locator('.bb-tabs.primary button[role="tab"][aria-selected="true"]').textContent()) ?? ""
@@ -307,7 +348,18 @@ const ab2Head = page
.locator(".eff-tree-head")
.filter({ has: page.locator(".eff-tree-head-label", { hasText: "Ability 2" }) });
await ab2Head.locator(".eff-tree-head-label").hover();
-await page.waitForTimeout(120);
+await page
+ .waitForFunction(
+ () => {
+ const labels = Array.from(document.querySelectorAll(".eff-tree-head-label"));
+ const label = labels.find((l) => (l.textContent ?? "").includes("Ability 2"));
+ const head = label?.closest(".eff-tree-head") as HTMLElement | null;
+ return !!head && getComputedStyle(head).backgroundColor !== "rgba(0, 0, 0, 0)";
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const hoverColors = await ab2Head.evaluate((head) => ({
row: getComputedStyle(head).backgroundColor,
label: getComputedStyle(head.querySelector(".eff-tree-head-label")!).backgroundColor,
@@ -400,7 +452,6 @@ check(
// at a deliberately narrow viewport (the 1280 default has enough slack to hide the bug) that no col-2 label
// whose row-band intersects the combobox has its left edge under the combobox.
await page.setViewportSize({ width: 1000, height: 900 });
-await page.waitForTimeout(120);
const narrowOverlap = await page.evaluate(() => {
const combo = document.querySelector(".eff-tree .detail .bb-combobox-input") as HTMLElement | null;
if (!combo) return { ok: false, detail: "no combobox" };
@@ -419,14 +470,22 @@ check(
narrowOverlap.detail,
);
await page.setViewportSize({ width: 1280, height: 900 });
-await page.waitForTimeout(120);
// Stable-columns guard: the col-2 "Timing" control's left edge must coincide across all three effects
// (different opcodes relabel parameter1/parameter2, but the fixed label column keeps the values put).
const timingLefts: number[] = [];
for (let i = 0; i < 3; i++) {
await page.locator(".eff-tree-effect").nth(i).click();
- await page.waitForTimeout(80);
+ await page
+ .waitForFunction(
+ (idx) => {
+ const rows = Array.from(document.querySelectorAll(".eff-tree-effect"));
+ return rows[idx]?.classList.contains("eff-tree-selected") ?? false;
+ },
+ i,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const left = await page
.locator('.eff-tree .detail .bb-combobox-input[aria-label="Timing"]')
.first()
@@ -504,14 +563,23 @@ check(
ddWidths.attack!.w < ddWidths.damage!.w,
JSON.stringify(ddWidths),
);
-await page.screenshot({ path: path.join(here, "shot-itm-tree.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-itm-tree.png"), fullPage: true });
// ============================================================
// Structure ops via the tree (full parity with the dropped Abilities/Effects tabs)
// ============================================================
// + ability (section-level add)
await page.locator(".eff-tree-toolbar .eff-tree-toolbtn").click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(
+ () =>
+ Array.from(document.querySelectorAll(".eff-tree-head-label")).filter((l) =>
+ (l.textContent ?? "").startsWith("Ability"),
+ ).length === 3,
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
check(
"ops: + ability adds an ability",
sectionKids(abilitiesNodeId).total === 3,
@@ -529,7 +597,9 @@ const ability1Head = page
.locator(".eff-tree-head")
.filter({ has: page.locator(".eff-tree-head-label", { hasText: "Ability 1" }) });
await ability1Head.locator(".eff-tree-add").click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 4, undefined, { timeout: 5000 })
+ .catch(() => undefined);
check(
"ops: + effect on Ability 1 adds an effect",
sectionKids(effectsNodeId).total === 4,
@@ -542,7 +612,9 @@ check("ops: undo restores 3 effects", sectionKids(effectsNodeId).total === 3, `$
await page.locator(".eff-tree-effect").first().click();
await page.locator(".eff-tree .detail .row-actions").first().waitFor({ timeout: 3000 });
await page.locator('.eff-tree .detail .row-actions button[aria-label="Delete"]').click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 2, undefined, { timeout: 5000 })
+ .catch(() => undefined);
check(
"ops: delete effect via RowActions removes it",
sectionKids(effectsNodeId).total === 2,
@@ -553,7 +625,16 @@ await doUndo();
await page.locator(".eff-tree-head-label", { hasText: "Ability 1" }).first().click();
await page.locator(".eff-tree .detail .row-actions").first().waitFor({ timeout: 3000 });
await page.locator('.eff-tree .detail .row-actions button[aria-label="Add below"]').click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(
+ () =>
+ Array.from(document.querySelectorAll(".eff-tree-head-label")).filter((l) =>
+ (l.textContent ?? "").startsWith("Ability"),
+ ).length === 3,
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
check(
"ops: ability RowActions Add below inserts an ability",
sectionKids(abilitiesNodeId).total === 3,
@@ -563,7 +644,9 @@ await doUndo();
// Add global (equipping) effect: the Global group's + appends to the equipping range (section-level add).
await page.locator('.eff-tree-add[aria-label="Add global effect"]').click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 4, undefined, { timeout: 5000 })
+ .catch(() => undefined);
check(
"ops: + global effect grows the effect count",
sectionKids(effectsNodeId).total === 4,
@@ -576,7 +659,11 @@ check("ops: undo restores 3 effects", sectionKids(effectsNodeId).total === 3, `$
// Filter: typing narrows the tree to matching effects (+ their owning headers), forcing groups expanded.
// ============================================================
await page.locator(".eff-tree-toolbar input.list-filter-input").fill("op 21");
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(() => document.querySelectorAll(".eff-tree-effect-label").length === 1, undefined, {
+ timeout: 5000,
+ })
+ .catch(() => undefined);
const filtered = await page.evaluate(() => ({
effects: Array.from(document.querySelectorAll(".eff-tree-effect-label")).map((e) => (e.textContent ?? "").trim()),
heads: Array.from(document.querySelectorAll(".eff-tree-head-label")).map((e) => (e.textContent ?? "").trim()),
@@ -590,7 +677,9 @@ check(
JSON.stringify(filtered),
);
await page.locator(".eff-tree-toolbar .list-filter-clear").click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 3, undefined, { timeout: 5000 })
+ .catch(() => undefined);
check(
"filter: clearing restores all three effects",
(await page.locator(".eff-tree-effect").count()) === 3,
@@ -599,14 +688,18 @@ check(
// Collapse all / expand all: collapsing hides every nested effect row (headers remain); expanding restores them.
await page.locator('.eff-tree-iconbtn[aria-label="Collapse all"]').click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 0, undefined, { timeout: 5000 })
+ .catch(() => undefined);
check(
"tree: collapse all hides every effect row",
(await page.locator(".eff-tree-effect").count()) === 0,
`${await page.locator(".eff-tree-effect").count()}`,
);
await page.locator('.eff-tree-iconbtn[aria-label="Expand all"]').click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 3, undefined, { timeout: 5000 })
+ .catch(() => undefined);
check(
"tree: expand all restores every effect row",
(await page.locator(".eff-tree-effect").count()) === 3,
@@ -629,7 +722,16 @@ check(
} else {
sessionId = r.result.sessionId;
await page.evaluate((rr) => window.postMessage(rr, "*"), { type: "init", open: r.result } as HostToWebview);
- await page.waitForTimeout(300);
+ await page
+ .waitForFunction(
+ () => {
+ const spacer = document.querySelector(".eff-tree-spacer") as HTMLElement | null;
+ return !!spacer && spacer.getBoundingClientRect().height > 3000;
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const mounted = await page.locator(".eff-tree-vrow").count();
const spacerH = await page
.locator(".eff-tree-spacer")
diff --git a/binary-editor/test/harness/render-map-banner.mts b/binary-editor/test/harness/render-map-banner.mts
index a16797da8..c00a98df8 100644
--- a/binary-editor/test/harness/render-map-banner.mts
+++ b/binary-editor/test/harness/render-map-banner.mts
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
const here = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = path.join(here, "../../../client/testFixture/maps/arcaves.map");
@@ -51,7 +52,9 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => {
await page.goto("file://" + path.join(here, "app.html"));
await page.waitForSelector(".layout-root", { timeout: 5000 });
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(() => document.querySelector(".banner.warning") !== null, undefined, { timeout: 5000 })
+ .catch(() => undefined);
const banner = await page.evaluate(() => {
const b = document.querySelector(".banner.warning");
@@ -72,7 +75,7 @@ if (banner) {
);
}
-await page.screenshot({ path: path.join(here, "shot-map-banner.png"), fullPage: false });
+await page.screenshot({ path: shotPath("shot-map-banner.png"), fullPage: false });
await browser.close();
console.log("\n=== MAP banner harness results ===");
diff --git a/binary-editor/test/harness/render-map-elevations.mts b/binary-editor/test/harness/render-map-elevations.mts
index 36b1de04a..2e90d0e5a 100644
--- a/binary-editor/test/harness/render-map-elevations.mts
+++ b/binary-editor/test/harness/render-map-elevations.mts
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
const here = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = path.join(here, "../../../client/testFixture/maps/artemple.map");
@@ -52,7 +53,13 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => {
await page.goto("file://" + path.join(here, "app.html"));
await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 });
await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: "Objects" }).first().click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(
+ () => document.querySelectorAll('.layout-root .bb-tabs.secondary button[role="tab"]').length >= 3,
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
// Read the elevation subtab strip: label + disabled state.
const subs = await page.evaluate(() =>
@@ -76,7 +83,7 @@ check(
JSON.stringify(byLabel("Elevation 2")),
);
-await page.screenshot({ path: path.join(here, "shot-map-elevations.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-map-elevations.png"), fullPage: true });
await browser.close();
console.log("\n=== MAP elevation-tab harness results ===");
diff --git a/binary-editor/test/harness/render-map-jump-deep.mts b/binary-editor/test/harness/render-map-jump-deep.mts
index b6f0e7251..913ea3e58 100644
--- a/binary-editor/test/harness/render-map-jump-deep.mts
+++ b/binary-editor/test/harness/render-map-jump-deep.mts
@@ -18,6 +18,7 @@ import { dispatch } from "../../src/index";
import type { FlatNode } from "../../src/model";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
const here = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = path.join(here, "../../../client/testFixture/maps/denbus1.map");
@@ -122,13 +123,21 @@ const fieldHex = (label: string) =>
// Open the linked script: Scripts tab -> its type subtab -> filter to its exact label -> click it.
await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: "Scripts" }).first().click();
-await page.waitForTimeout(150);
await page.locator('.layout-root .bb-tabs button[role="tab"]').filter({ hasText: deep.scriptType }).first().click();
-await page.waitForTimeout(150);
await page.locator(".layout-root .list-filter-input").first().fill(deep.scriptLabel);
-await page.waitForTimeout(200);
await page.locator(".layout-root .vlist .vrow").filter({ hasText: deep.scriptLabel }).first().click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(
+ () => {
+ const field = Array.from(document.querySelectorAll(".layout-root .field")).find(
+ (f) => f.querySelector(".label")?.textContent?.trim() === "SID",
+ );
+ return field !== undefined && field.querySelector(".jump-link") !== null;
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const sidChip = page
.locator(".layout-root .field")
@@ -137,7 +146,24 @@ const sidChip = page
.locator(".jump-link");
check("the deep-linked script exposes a SID jump chip", (await sidChip.count()) > 0, deep.scriptLabel);
await sidChip.first().click();
-await page.waitForTimeout(300);
+
+// A deep jump is two async round-trips: locateEntry fetches the full object list to resolve the selection
+// (the target sits past the bounded window), THEN the detail pane fetches and renders the landed object's
+// fields. Poll for the object's SID field to render rather than racing a fixed sleep - the old 300ms sleep
+// read the still-empty detail pane and saw null even though the jump had landed correctly.
+await page
+ .waitForFunction(
+ () => {
+ const field = Array.from(document.querySelectorAll(".layout-root .field")).find(
+ (f) => f.querySelector(".label")?.textContent?.trim() === "SID",
+ );
+ const input = field?.querySelector(".hex-input input") as HTMLInputElement | null;
+ return input !== null && input.value.length > 0;
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined); // let the assertions below report the concrete failure rather than a poll timeout
const tabAfter = (await activePrimaryTab()).trim();
check("jump switches to the Objects tab", tabAfter.startsWith("Objects"), `active="${tabAfter}"`);
@@ -148,7 +174,7 @@ check(
landedSid !== null && (parseInt(landedSid, 16) | 0) === (deep.objSid | 0),
`landed=0x${landedSid} expected=0x${objSidHex} targetIdx=${deep.objIndex}`,
);
-await page.screenshot({ path: path.join(here, "shot-map-jump-deep.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-map-jump-deep.png"), fullPage: true });
await browser.close();
console.log("\n=== MAP deep-jump harness results ===");
diff --git a/binary-editor/test/harness/render-map-jump.mts b/binary-editor/test/harness/render-map-jump.mts
index 086a48d09..a669c0d1e 100644
--- a/binary-editor/test/harness/render-map-jump.mts
+++ b/binary-editor/test/harness/render-map-jump.mts
@@ -14,6 +14,7 @@ import { buildFileDerivedParseOptions } from "@bgforge/binary";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
const here = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = path.join(here, "../../../client/testFixture/maps/denbus1.map");
@@ -69,7 +70,18 @@ const fieldHex = (label: string) =>
// --- Find a script whose SID links to its object. Only object-owned scripts (item/critter) are referenced by
// an object, so scan each script subtab's first rows until one exposes a SID chip. ---
await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: "Scripts" }).first().click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(
+ () =>
+ Array.from(document.querySelectorAll('.layout-root .bb-tabs button[role="tab"]')).some((b) =>
+ ["System", "Spatial", "Timer", "Item", "Critter"].some((t) =>
+ (b.textContent ?? "").trim().startsWith(t),
+ ),
+ ),
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const sidField = () =>
page
.locator(".layout-root .field")
@@ -82,12 +94,25 @@ for (let s = 0; s < (await subtabs.count()) && sidHex === null; s++) {
const label = (await subtabs.nth(s).innerText()).trim();
if (!["System", "Spatial", "Timer", "Item", "Critter"].some((t) => label.startsWith(t))) continue;
await subtabs.nth(s).click();
- await page.waitForTimeout(120);
+ await page
+ .waitForFunction(() => document.querySelectorAll(".layout-root .vlist .vrow").length > 0, undefined, {
+ timeout: 5000,
+ })
+ .catch(() => undefined);
const rows = page.locator(".layout-root .vlist .vrow");
const rowCount = Math.min(await rows.count(), 20);
for (let i = 0; i < rowCount; i++) {
await rows.nth(i).click();
- await page.waitForTimeout(70);
+ await page
+ .waitForFunction(
+ (idx) => {
+ const el = document.querySelectorAll(".layout-root .vlist .vrow")[idx];
+ return el !== undefined && el.classList.contains("selected");
+ },
+ i,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
if ((await sidField().locator(".jump-link").count()) > 0) {
sidHex = await fieldHex("SID");
jumpLabel = (await sidField().locator(".jump-link").first().innerText()).trim();
@@ -100,7 +125,19 @@ check("found a script whose SID links to its object", sidHex !== null, `sid=0x${
if (sidHex !== null) {
const sidVal = parseInt(sidHex, 16) | 0;
await sidField().locator(".jump-link").first().click();
- await page.waitForTimeout(200);
+ await page
+ .waitForFunction(
+ () => {
+ const field = Array.from(document.querySelectorAll(".layout-root .field")).find(
+ (f) => f.querySelector(".label")?.textContent?.trim() === "SID",
+ );
+ const input = field?.querySelector(".hex-input input") as HTMLInputElement | null;
+ return input !== null && input.value.length > 0;
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const tabAfter = (await activePrimaryTab()).trim();
check("the script SID jump switches to the Objects tab", tabAfter.startsWith("Objects"), `active="${tabAfter}"`);
@@ -129,7 +166,7 @@ if (sidHex !== null) {
JSON.stringify(visible),
);
- await page.screenshot({ path: path.join(here, "shot-map-jump.png"), fullPage: true });
+ await page.screenshot({ path: shotPath("shot-map-jump.png"), fullPage: true });
}
// The reverse direction (object SID -> script) uses the identical navigate() primitive with the script
// section key; the link resolution both ways is covered by binary-editor/test/map-cross-links.test.ts.
diff --git a/binary-editor/test/harness/render-map-scripts.mts b/binary-editor/test/harness/render-map-scripts.mts
index e83344f43..a505c581f 100644
--- a/binary-editor/test/harness/render-map-scripts.mts
+++ b/binary-editor/test/harness/render-map-scripts.mts
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
const here = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = path.join(here, "../../../client/testFixture/maps/newr2.map");
@@ -55,7 +56,18 @@ await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 });
// Primary tab -> Scripts.
await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: "Scripts" }).first().click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(
+ () =>
+ Array.from(document.querySelectorAll('.layout-root .bb-tabs button[role="tab"]')).some((b) =>
+ ["System", "Spatial", "Timer", "Item", "Critter"].some((t) =>
+ (b.textContent ?? "").trim().startsWith(t),
+ ),
+ ),
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
// Pick the first script subtab that has list rows.
const subtabs = page.locator('.layout-root .bb-tabs button[role="tab"]');
@@ -66,7 +78,11 @@ for (let i = 0; i < subCount; i++) {
const label = (await subtabs.nth(i).innerText()).trim();
if (!["System", "Spatial", "Timer", "Item", "Critter"].some((s) => label.startsWith(s))) continue;
await subtabs.nth(i).click();
- await page.waitForTimeout(150);
+ await page
+ .waitForFunction(() => document.querySelectorAll(".layout-root .vlist .vrow").length > 0, undefined, {
+ timeout: 5000,
+ })
+ .catch(() => undefined);
const rows = page.locator(".layout-root .vlist .vrow");
if ((await rows.count()) > 0) {
selected = rows;
@@ -85,7 +101,16 @@ if (selected) {
);
await selected.first().click();
- await page.waitForTimeout(150);
+ await page
+ .waitForFunction(
+ () =>
+ Array.from(document.querySelectorAll(".layout-root .field")).some(
+ (f) => f.querySelector(".label")?.textContent?.trim() === "SID",
+ ),
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
// SID field in the selected script's detail: a hex control (`.hex-input` = static "0x" prefix + digit
// input) whose digits are not clipped. The label is the stripped "SID" (no "Entry N" prefix).
@@ -116,7 +141,7 @@ if (selected) {
);
check("extent paging fields are hidden", extentLabels === 0, `count=${extentLabels}`);
- await page.screenshot({ path: path.join(here, "shot-map-scripts.png"), fullPage: true });
+ await page.screenshot({ path: shotPath("shot-map-scripts.png"), fullPage: true });
}
await browser.close();
diff --git a/binary-editor/test/harness/render-map-vars.mts b/binary-editor/test/harness/render-map-vars.mts
index 4727accc1..49d757bb5 100644
--- a/binary-editor/test/harness/render-map-vars.mts
+++ b/binary-editor/test/harness/render-map-vars.mts
@@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
const here = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = path.join(here, "../../../client/testFixture/maps/arcaves.map");
@@ -69,7 +70,18 @@ check(
);
await varTabBtn.click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(
+ () => {
+ const btn = Array.from(document.querySelectorAll('.bb-tabs.primary button[role="tab"]')).find((b) =>
+ (b.textContent ?? "").includes("Variables"),
+ );
+ return btn !== undefined && /Variables\s*\(\s*21\s*\)/.test(btn.textContent ?? "");
+ },
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
// Variables tab button should show a count badge (21 from arcaves).
const varTabLabel = await varTabBtn.innerText();
@@ -92,7 +104,7 @@ const rows = page.locator(".layout-root .vlist .vrow");
const rowCount = await rows.count();
check("Global subtab shows variable list rows (>0)", rowCount > 0, `rows=${rowCount}`);
-await page.screenshot({ path: path.join(here, "shot-map-vars.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-map-vars.png"), fullPage: true });
await browser.close();
console.log("\n=== MAP variables tab harness results ===");
diff --git a/binary-editor/test/harness/render-map.mts b/binary-editor/test/harness/render-map.mts
index 346a02d6e..f3046bc54 100644
--- a/binary-editor/test/harness/render-map.mts
+++ b/binary-editor/test/harness/render-map.mts
@@ -27,6 +27,7 @@ import { fileURLToPath } from "node:url";
import { dispatch } from "../../src/index";
import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
import { mapParser } from "../../../binary/src/map/index";
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -101,10 +102,21 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => {
});
await page.goto("file://" + path.join(here, "app.html"));
await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 });
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(() => document.querySelector(".layout-root .panel > h3") !== null, undefined, { timeout: 5000 })
+ .catch(() => undefined);
async function clickTab(label: string): Promise {
await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: label }).first().click();
- await page.waitForTimeout(200);
+ await page
+ .waitForFunction(
+ (lbl) => {
+ const active = document.querySelector('.bb-tabs.primary button[role="tab"][aria-selected="true"]');
+ return active !== null && (active.textContent ?? "").includes(lbl);
+ },
+ label,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
}
// Guard against the "captured the wrong tab" regression: each per-tab screenshot must be taken while that
// tab is actually the selected one. Returns the active primary tab's label (includes its count badge text).
@@ -118,7 +130,7 @@ check(
(await activeTabLabel()).includes("Header"),
await activeTabLabel(),
);
-await page.screenshot({ path: path.join(here, "shot-map.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-map.png"), fullPage: true });
// ============================================================
// Layout assertions
@@ -184,13 +196,17 @@ check("layout: objects tab renders an elevation object list", elevMd >= 1, `coun
// Select the first object so the detail pane renders (otherwise the shot is just an empty "Select an entry." pane).
await objectsMd.locator(".vlist .vrow").first().waitFor({ timeout: 5000 });
await objectsMd.locator(".vlist .vrow").first().click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(() => document.querySelector(".master-detail .detail .detail-tabs") !== null, undefined, {
+ timeout: 5000,
+ })
+ .catch(() => undefined);
check(
"screenshot: Objects tab active for shot-map-objects",
(await activeTabLabel()).includes("Objects"),
await activeTabLabel(),
);
-await page.screenshot({ path: path.join(here, "shot-map-objects.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-map-objects.png"), fullPage: true });
// The object detail splits into "Details" + "Inventory" tabs; the engine Inventory Header bookkeeping group is
// hidden, so it must NOT appear as a form sub-tab. Open the Inventory tab to reach the mini-list.
@@ -208,7 +224,9 @@ check(
"",
);
await detailTabs.locator('button[role="tab"]').filter({ hasText: "Inventory" }).first().click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(() => document.querySelector(".child-list") !== null, undefined, { timeout: 5000 })
+ .catch(() => undefined);
// Inventory mini-list (interactive): cave6's first object has no inventory, so the list opens on its empty
// state. Add two items through the "+ add item" button (real addChild dispatch -> changeSet refresh), expand
@@ -224,9 +242,12 @@ check(
.catch(() => "")) ?? "",
);
await inventoryList.locator(".child-list-add").click();
-await page.waitForTimeout(150);
await inventoryList.locator(".child-list-add").click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(() => document.querySelectorAll(".child-list .child-row").length >= 2, undefined, {
+ timeout: 5000,
+ })
+ .catch(() => undefined);
const invRows = await inventoryList.locator(".child-row").count();
check("inventory: + add item grew the list to two rows", invRows === 2, `rows=${invRows}`);
const invTabText =
@@ -242,7 +263,11 @@ check(
`label="${invTabText.trim()}"`,
);
await inventoryList.locator(".child-row .child-row-label").first().click();
-await page.waitForTimeout(200);
+await page
+ .waitForFunction(() => document.querySelector(".child-list .child-row-detail .form") !== null, undefined, {
+ timeout: 5000,
+ })
+ .catch(() => undefined);
// The row label is the item's identity (PID) + quantity, not the bare "Inventory Entry N" group name.
const firstRowLabel = (await inventoryList.locator(".child-row .child-row-label").first().textContent()) ?? "";
check(
@@ -255,7 +280,7 @@ check(
(await inventoryList.locator(".child-row-detail .form").count()) >= 1,
"",
);
-await page.screenshot({ path: path.join(here, "shot-map-inventory.png"), fullPage: true });
+await page.screenshot({ path: shotPath("shot-map-inventory.png"), fullPage: true });
// ============================================================
// Baseline + structure ops (dispatch-level; the interactive DOM path is covered by ITM/CRE)
diff --git a/binary-editor/test/harness/render-primitives.mts b/binary-editor/test/harness/render-primitives.mts
index 5df870cb3..df95ff866 100644
--- a/binary-editor/test/harness/render-primitives.mts
+++ b/binary-editor/test/harness/render-primitives.mts
@@ -28,6 +28,7 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { installCspGate } from "./csp-gate";
+import { shotPath } from "./out-dir";
import { THEME_VARS } from "./theme-vars";
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -97,15 +98,18 @@ const comboboxAllCount = await page.locator(".bb-combobox-item").count();
// SelectInputState picks up (setting inputValue.current) and our handleInput handler picks up (updating our
// local inputValue state, driving $derived visibleOptions). The dropdown is already open from ArrowDown.
await page.locator(".bb-combobox-input").pressSequentially("fireball");
-// Give Svelte reactive updates a moment to re-render the filtered list.
-await page.waitForTimeout(200);
+// Wait for Svelte reactive updates to re-render the filtered list (item count drops below the unfiltered total).
+await page
+ .waitForFunction((all) => document.querySelectorAll(".bb-combobox-item").length < all, comboboxAllCount, {
+ timeout: 5000,
+ })
+ .catch(() => undefined);
const comboboxFilteredCount = await page.locator(".bb-combobox-item").count();
// Close the combobox dropdown (still open from the filter step) before interacting with the Checkbox.
// Wait for the content to detach (bits-ui resets body.style.pointerEvents only after close animation).
await page.keyboard.press("Escape");
await page.waitForSelector(".bb-combobox-content", { state: "detached", timeout: 3000 }).catch(() => {});
-await page.waitForTimeout(150);
// ---- Exercise Checkbox: click checkbox-a (unchecked -> checked) and assert data-state changes ----
// bits-ui sets data-state="checked"|"unchecked" on Checkbox.Root (role="checkbox"). We locate it inside
@@ -114,18 +118,33 @@ await page.waitForSelector("#checkbox-a [role='checkbox']", { timeout: 5000 });
const checkboxABefore = await page.locator("#checkbox-a [role='checkbox']").getAttribute("data-state");
await page.locator("#checkbox-a [role='checkbox']").click();
-await page.waitForTimeout(100);
+await page
+ .waitForFunction(
+ (before) => document.querySelector("#checkbox-a [role='checkbox']")?.getAttribute("data-state") !== before,
+ checkboxABefore,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const checkboxAAfter = await page.locator("#checkbox-a [role='checkbox']").getAttribute("data-state");
// Also confirm checkbox-b starts checked and can be untoggled.
const checkboxBBefore = await page.locator("#checkbox-b [role='checkbox']").getAttribute("data-state");
await page.locator("#checkbox-b [role='checkbox']").click();
-await page.waitForTimeout(100);
+await page
+ .waitForFunction(
+ (before) => document.querySelector("#checkbox-b [role='checkbox']")?.getAttribute("data-state") !== before,
+ checkboxBBefore,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const checkboxBAfter = await page.locator("#checkbox-b [role='checkbox']").getAttribute("data-state");
// Disabled checkbox must not change on click.
const checkboxDisabledBefore = await page.locator("#checkbox-disabled [role='checkbox']").getAttribute("data-state");
await page.locator("#checkbox-disabled [role='checkbox']").click({ force: true });
+// No state-change event to poll for here: correct behavior is "nothing happens" on a disabled control, so there
+// is no becomes-true condition to wait on. Keep a bounded settle so a real bug (an incorrect state flip) has
+// time to land before the read below.
await page.waitForTimeout(100);
const checkboxDisabledAfter = await page.locator("#checkbox-disabled [role='checkbox']").getAttribute("data-state");
@@ -146,7 +165,9 @@ const checkboxDisabledNamedCorrectly =
// does not affect item label text or role - assert on labels and roles only, not icon visuals.
// Menu.svelte passes preventScroll={false} so it does not set body.style.pointerEvents:none (unlike the
// prior combobox). Ensure the combobox scroll-lock (if any) is cleared before clicking.
-await page.waitForFunction(() => document.body.style.pointerEvents !== "none", { timeout: 500 }).catch(() => {});
+await page
+ .waitForFunction(() => document.body.style.pointerEvents !== "none", undefined, { timeout: 500 })
+ .catch(() => {});
await page.waitForSelector("#menu-showcase .bb-menu-trigger", { timeout: 5000 });
await page.locator("#menu-showcase .bb-menu-trigger").click();
// Wait for at least one menu item to appear (the content is portalled to document body).
@@ -157,7 +178,13 @@ const menuItemCount = await page.locator(".bb-menu-item").count();
// Assert by label text (not icon) so missing codicon glyphs don't interfere.
const addAboveItem = page.locator(".bb-menu-item", { hasText: "Add above" });
await addAboveItem.click();
-await page.waitForTimeout(100);
+await page
+ .waitForFunction(
+ () => document.querySelector("#menu-selected")?.getAttribute("data-value") === "add-above",
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const menuSelectedAfterAdd = await page.locator("#menu-selected").getAttribute("data-value");
// Re-open the menu to test the disabled item. Menu.svelte uses preventScroll={false} so no scroll-lock
@@ -170,12 +197,14 @@ const disabledItem = page.locator(".bb-menu-item[data-disabled]");
const disabledItemCount = await disabledItem.count();
// Click the disabled item - it must not fire onselect (the selected value must not change).
await disabledItem.click({ force: true });
+// No state-change event to poll for here: correct behavior is "nothing happens" on a disabled item, so there
+// is no becomes-true condition to wait on. Keep a bounded settle so a real bug (an incorrect onselect fire) has
+// time to land before the read below.
await page.waitForTimeout(100);
const menuSelectedAfterDisabled = await page.locator("#menu-selected").getAttribute("data-value");
// Close the menu before the screenshot.
await page.keyboard.press("Escape");
-await page.waitForTimeout(150);
// ---- Exercise Tabs (horizontal): assert initial active, click a different tab, assert selection moves ----
// The showcase reflects active tab id into #tabs-h-active[data-value] so we can assert without reading
@@ -185,12 +214,24 @@ const tabsHInitial = await page.locator("#tabs-h-active").getAttribute("data-val
// Click the "Abilities" tab (second tab, id="abilities").
const tabsHAbilities = page.locator("#tabs-h-showcase [role='tab'][aria-selected]", { hasText: "Abilities" });
await tabsHAbilities.click();
-await page.waitForTimeout(100);
+await page
+ .waitForFunction(
+ () => document.querySelector("#tabs-h-active")?.getAttribute("data-value") === "abilities",
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const tabsHAfterClick = await page.locator("#tabs-h-active").getAttribute("data-value");
// Arrow-key nav: with "Abilities" now active, press ArrowRight to move to "Effects".
await page.keyboard.press("ArrowRight");
-await page.waitForTimeout(100);
+await page
+ .waitForFunction(
+ () => document.querySelector("#tabs-h-active")?.getAttribute("data-value") === "effects",
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const tabsHAfterArrow = await page.locator("#tabs-h-active").getAttribute("data-value");
// ---- Exercise Tabs (vertical): assert initial active, click a different tab, assert selection moves ----
@@ -199,12 +240,24 @@ const tabsVInitial = await page.locator("#tabs-v-active").getAttribute("data-val
// Click the "Effects" tab (third tab, id="effects").
const tabsVEffects = page.locator("#tabs-v-showcase [role='tab']", { hasText: "Effects" });
await tabsVEffects.click();
-await page.waitForTimeout(100);
+await page
+ .waitForFunction(
+ () => document.querySelector("#tabs-v-active")?.getAttribute("data-value") === "effects",
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const tabsVAfterClick = await page.locator("#tabs-v-active").getAttribute("data-value");
// Arrow-key nav: with "Effects" now active, press ArrowUp to move to "Abilities" (vertical orientation).
await page.keyboard.press("ArrowUp");
-await page.waitForTimeout(100);
+await page
+ .waitForFunction(
+ () => document.querySelector("#tabs-v-active")?.getAttribute("data-value") === "abilities",
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const tabsVAfterArrow = await page.locator("#tabs-v-active").getAttribute("data-value");
// ---- Exercise compact RowActions: kebab-only layout + Menu->Delete fires immediately ----
@@ -220,10 +273,17 @@ const compactDirectButtons = await page
await page.locator("#rowactions-compact .bb-menu-trigger").click();
await page.waitForSelector(".bb-menu-item", { timeout: 5000 });
await page.locator(".bb-menu-item", { hasText: "Delete" }).click();
-await page.waitForTimeout(150);
+await page
+ .waitForFunction(
+ () =>
+ (document.querySelector("#rowactions-last-op")?.getAttribute("data-value") ?? "").includes('"op":"remove"'),
+ undefined,
+ { timeout: 5000 },
+ )
+ .catch(() => undefined);
const opAfterMenuDelete = await page.locator("#rowactions-last-op").getAttribute("data-value");
-await page.screenshot({ path: path.join(here, "shot-primitives.png") });
+await page.screenshot({ path: shotPath("shot-primitives.png") });
// Diagnostic: enumerate elements carrying a style attribute and any injected
+