Skip to content

Commit 721c138

Browse files
committed
test(web): exercise command directory behavior
1 parent 4efb48d commit 721c138

5 files changed

Lines changed: 292 additions & 10 deletions

File tree

apps/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"dev": "next dev",
77
"build": "next build",
88
"start": "next start",
9-
"lint": "node scripts/validate-harness-onboarding.mjs && node scripts/validate-content-provenance.mjs && node scripts/validate-command-directory.mjs && node scripts/validate-bundle-policy.mjs && node scripts/validate-deployment-config.mjs && eslint .",
9+
"lint": "node scripts/validate-harness-onboarding.mjs && node scripts/validate-content-provenance.mjs && node scripts/validate-command-directory.mjs && node --test scripts/command-directory.test.mjs && node scripts/validate-bundle-policy.mjs && node scripts/validate-deployment-config.mjs && eslint .",
1010
"brand": "node scripts/render-brand.mjs"
1111
},
1212
"dependencies": {
@@ -24,6 +24,7 @@
2424
"@types/react-dom": "^19.2.7",
2525
"eslint": "^9.36.0",
2626
"eslint-config-next": "^16.3.3",
27+
"jsdom": "30.0.1",
2728
"tailwindcss": "^4.1.11",
2829
"typescript": "^6.0.3"
2930
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import assert from "node:assert/strict";
2+
import * as nodeModule from "node:module";
3+
import test from "node:test";
4+
import { JSDOM } from "jsdom";
5+
import * as componentTestLoader from "./component-test-loader.mjs";
6+
import { loadProductDocsContent } from "../lib/repository-content.mjs";
7+
8+
if (typeof nodeModule.registerHooks === "function") {
9+
nodeModule.registerHooks(componentTestLoader);
10+
} else {
11+
nodeModule.register("./component-test-loader.mjs", import.meta.url);
12+
}
13+
14+
const { groups } = loadProductDocsContent().commands;
15+
16+
async function renderDirectory({ width = 1280 } = {}) {
17+
const dom = new JSDOM('<div id="root"></div>', {
18+
pretendToBeVisual: true,
19+
url: "https://headless.test/docs/commands",
20+
});
21+
Object.defineProperty(dom.window, "innerWidth", {
22+
configurable: true,
23+
value: width,
24+
});
25+
26+
const previousGlobals = new Map();
27+
for (const name of [
28+
"document",
29+
"Event",
30+
"HTMLElement",
31+
"HTMLInputElement",
32+
"InputEvent",
33+
"KeyboardEvent",
34+
"Node",
35+
"navigator",
36+
"window",
37+
]) {
38+
previousGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
39+
Object.defineProperty(globalThis, name, {
40+
configurable: true,
41+
value: dom.window[name],
42+
writable: true,
43+
});
44+
}
45+
previousGlobals.set(
46+
"IS_REACT_ACT_ENVIRONMENT",
47+
Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT"),
48+
);
49+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
50+
configurable: true,
51+
value: true,
52+
writable: true,
53+
});
54+
55+
const [{ act, createElement }, { createRoot }, { CommandDirectory }] =
56+
await Promise.all([
57+
import("react"),
58+
import("react-dom/client"),
59+
import("@/components/command-directory"),
60+
]);
61+
const container = dom.window.document.querySelector("#root");
62+
const root = createRoot(container);
63+
await act(() => root.render(createElement(CommandDirectory, { groups })));
64+
65+
return {
66+
act,
67+
document: dom.window.document,
68+
input: dom.window.document.querySelector("input[type=search]"),
69+
async cleanup() {
70+
await act(() => root.unmount());
71+
dom.window.close();
72+
for (const [name, descriptor] of previousGlobals) {
73+
if (descriptor === undefined) delete globalThis[name];
74+
else Object.defineProperty(globalThis, name, descriptor);
75+
}
76+
},
77+
};
78+
}
79+
80+
async function typeWithKeyboard(view, value) {
81+
const valueSetter = Object.getOwnPropertyDescriptor(
82+
view.input.ownerDocument.defaultView.HTMLInputElement.prototype,
83+
"value",
84+
).set;
85+
86+
view.input.focus();
87+
await view.act(() => {
88+
for (let index = 0; index < value.length; index += 1) {
89+
const key = value[index];
90+
view.input.dispatchEvent(
91+
new KeyboardEvent("keydown", { bubbles: true, key }),
92+
);
93+
valueSetter.call(view.input, value.slice(0, index + 1));
94+
view.input.dispatchEvent(
95+
new InputEvent("input", {
96+
bubbles: true,
97+
data: key,
98+
inputType: "insertText",
99+
}),
100+
);
101+
view.input.dispatchEvent(
102+
new KeyboardEvent("keyup", { bubbles: true, key }),
103+
);
104+
}
105+
});
106+
}
107+
108+
async function replaceSearch(view, value) {
109+
const valueSetter = Object.getOwnPropertyDescriptor(
110+
view.input.ownerDocument.defaultView.HTMLInputElement.prototype,
111+
"value",
112+
).set;
113+
await view.act(() => {
114+
valueSetter.call(view.input, value);
115+
view.input.dispatchEvent(
116+
new InputEvent("input", {
117+
bubbles: true,
118+
data: value,
119+
inputType: "insertReplacementText",
120+
}),
121+
);
122+
});
123+
}
124+
125+
function assertTocTargetsExist(document) {
126+
for (const link of document.querySelectorAll("nav[aria-label='On this page'] a")) {
127+
assert.ok(
128+
document.querySelector(link.hash),
129+
`table-of-contents target ${link.hash} must exist`,
130+
);
131+
}
132+
}
133+
134+
test("filters real command groups from keyboard input and updates accessible status", async () => {
135+
const view = await renderDirectory();
136+
try {
137+
const label = view.document.querySelector("label[for=command-filter]");
138+
const status = view.document.querySelector("[role=status]");
139+
140+
assert.equal(label.control, view.input);
141+
assert.equal(view.input.tabIndex, 0);
142+
assert.equal(status.getAttribute("aria-live"), "polite");
143+
assert.equal(status.textContent, `${groups.length} of ${groups.length} groups`);
144+
assertTocTargetsExist(view.document);
145+
146+
await typeWithKeyboard(view, "auth login");
147+
148+
assert.equal(view.document.activeElement, view.input);
149+
assert.equal(status.textContent, `1 of ${groups.length} groups`);
150+
assert.deepEqual(
151+
[...view.document.querySelectorAll("section h2")].map(
152+
(heading) => heading.textContent,
153+
),
154+
["Credential vault"],
155+
);
156+
assert.deepEqual(
157+
[...view.document.querySelectorAll("nav[aria-label='On this page'] a")].map(
158+
(link) => link.textContent,
159+
),
160+
["Credential vault"],
161+
);
162+
assertTocTargetsExist(view.document);
163+
} finally {
164+
await view.cleanup();
165+
}
166+
});
167+
168+
test("handles normalized queries, no matches, and clearing without stale links", async () => {
169+
const view = await renderDirectory();
170+
try {
171+
const status = view.document.querySelector("[role=status]");
172+
173+
await replaceSearch(view, " SCREENSHOT ");
174+
assert.equal(status.textContent, `1 of ${groups.length} groups`);
175+
assert.equal(
176+
view.document.querySelector("section")?.id,
177+
"capture-and-evidence",
178+
);
179+
assertTocTargetsExist(view.document);
180+
181+
await replaceSearch(view, "not-a-command");
182+
assert.equal(status.textContent, "No commands match “not-a-command”.");
183+
assert.equal(view.document.querySelector("section"), null);
184+
assert.equal(view.document.querySelector("nav[aria-label='On this page']"), null);
185+
186+
await replaceSearch(view, "");
187+
assert.equal(status.textContent, `${groups.length} of ${groups.length} groups`);
188+
assert.equal(view.document.querySelectorAll("section").length, groups.length);
189+
assertTocTargetsExist(view.document);
190+
} finally {
191+
await view.cleanup();
192+
}
193+
});
194+
195+
test("keeps command discovery functional in a mobile viewport", async () => {
196+
const view = await renderDirectory({ width: 375 });
197+
try {
198+
assert.equal(view.document.defaultView.innerWidth, 375);
199+
assert.ok(view.input);
200+
assert.equal(view.document.querySelectorAll("section").length, groups.length);
201+
assertTocTargetsExist(view.document);
202+
203+
await typeWithKeyboard(view, "visit");
204+
assert.equal(
205+
view.document.querySelector("[role=status]").textContent,
206+
`1 of ${groups.length} groups`,
207+
);
208+
assert.equal(
209+
view.document.querySelector("nav[aria-label='On this page'] a").hash,
210+
"#navigation-and-interaction",
211+
);
212+
assert.equal(
213+
view.document.querySelector("section")?.id,
214+
"navigation-and-interaction",
215+
);
216+
} finally {
217+
await view.cleanup();
218+
}
219+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { accessSync, readFileSync } from "node:fs";
2+
import { dirname, extname, join } from "node:path";
3+
import { fileURLToPath, pathToFileURL } from "node:url";
4+
import { transformSync } from "next/dist/build/swc/index.js";
5+
6+
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
7+
const sourceExtensions = ["", ".tsx", ".ts", ".jsx", ".js", ".mjs"];
8+
9+
export function resolve(specifier, context, nextResolve) {
10+
if (!specifier.startsWith("@/")) return nextResolve(specifier, context);
11+
12+
const sourcePath = join(webRoot, specifier.slice(2));
13+
for (const extension of sourceExtensions) {
14+
const candidate = `${sourcePath}${extension}`;
15+
try {
16+
accessSync(candidate);
17+
return { shortCircuit: true, url: pathToFileURL(candidate).href };
18+
} catch {
19+
// Try the next supported source extension.
20+
}
21+
}
22+
23+
throw new Error(`Cannot resolve web source import: ${specifier}`);
24+
}
25+
26+
export function load(url, context, nextLoad) {
27+
const extension = extname(new URL(url).pathname);
28+
if (extension === ".json") {
29+
const source = readFileSync(fileURLToPath(url), "utf8");
30+
return {
31+
format: "module",
32+
shortCircuit: true,
33+
source: `export default ${source};`,
34+
};
35+
}
36+
if (extension !== ".tsx" && extension !== ".ts") {
37+
return nextLoad(url, context);
38+
}
39+
40+
const filename = fileURLToPath(url);
41+
const source = readFileSync(filename, "utf8");
42+
const output = transformSync(source, {
43+
filename,
44+
sourceMaps: false,
45+
jsc: {
46+
parser: {
47+
syntax: "typescript",
48+
tsx: extension === ".tsx",
49+
},
50+
target: "es2022",
51+
transform: {
52+
react: {
53+
runtime: "automatic",
54+
},
55+
},
56+
},
57+
module: {
58+
type: "es6",
59+
},
60+
});
61+
62+
return {
63+
format: "module",
64+
shortCircuit: true,
65+
source: output.code,
66+
};
67+
}

apps/web/scripts/validate-command-directory.mjs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@ const commands = await readFile(
1212
join(root, "apps/headless/docs/COMMANDS.md"),
1313
"utf8",
1414
);
15-
const directory = await readFile(
16-
join(root, "apps/web/components/command-directory.tsx"),
17-
"utf8",
18-
);
19-
2015
const sessionModel = sessionModelFromCommands(commands);
2116
assert.match(sessionModel, /one browser profile/i);
2217
assert.doesNotMatch(sessionModel, /stays isolated until you close it/i);
@@ -30,12 +25,9 @@ assert.ok(
3025
surface.groups.some((group) => /auth login/.test(group.usage)),
3126
"command directory must include auth login",
3227
);
33-
assert.match(directory, /role="status"/);
34-
assert.match(directory, /aria-live="polite"/);
35-
assert.match(directory, /docs-toc-inline/);
3628
assert.doesNotMatch(
3729
await readFile(join(root, "apps/web/app/docs/commands/page.tsx"), "utf8"),
3830
/sections=\{commands\.groups/,
3931
);
4032

41-
console.log("command directory search and accessibility checks passed");
33+
console.log("command directory content checks passed");

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)