Skip to content

Commit 76ea304

Browse files
author
DavidQ
committed
Add Playwright tool-level validation for all tools based on audit to eliminate manual verification - PR_26124_010-expand-playwright-to-tool-level
1 parent 5c6dfa8 commit 76ea304

8 files changed

Lines changed: 339 additions & 132 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# PR_26124_010 Report
2+
3+
## Goal
4+
Extend Playwright validation to cover each tool listed in `docs/dev/reports/tool_completion_audit.md`.
5+
6+
## Audit Parsing
7+
Extracted tool IDs from audit report:
8+
- `workspace-v2`
9+
- `asset-manager-v2`
10+
- `palette-manager-v2`
11+
- `svg-asset-studio-v2`
12+
- `tilemap-studio-v2`
13+
- `vector-map-editor-v2`
14+
15+
## Test Additions
16+
Added `tests/playwright/tool-validation/workspace-v2.tool-validation.spec.js` with:
17+
- Audit list verification.
18+
- Workspace import acceptance/rejection checks.
19+
- Per-tool valid payload render checks.
20+
- Per-tool invalid payload rejection checks (no partial valid render).
21+
22+
## Suite Integration
23+
`npm run test:workspace-v2` now runs:
24+
- `tests/ui/workspace-v2.asset-manager.spec.js`
25+
- `tests/playwright/workspace-v2.validation.spec.js`
26+
- `tests/playwright/tool-validation/*`
27+
28+
Playwright config now includes both:
29+
- `tests/ui`
30+
- `tests/playwright`
31+
32+
## Validation Run
33+
- `node --check scripts/run-workspace-v2-playwright-gate.mjs`
34+
- `node --check tests/helpers/playwrightRepoServer.mjs`
35+
- `node --check tests/ui/workspace-v2.asset-manager.spec.js`
36+
- `node --check tests/playwright/workspace-v2.validation.spec.js`
37+
- `node --check tests/playwright/tool-validation/workspace-v2.tool-validation.spec.js`
38+
- `node --check playwright.config.cjs`
39+
- `npm run test:workspace-v2`
40+
41+
Result: `19 passed`, `0 failed`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"check:phase24-closeout-guard": "node tools/dev/checkPhase24CloseoutExecutionGuard.mjs",
1212
"check:style-system-guard": "node tools/dev/checkStyleSystemGuard.mjs",
1313
"check:games-template-contract": "node ./scripts/validate-games-template-contract.mjs",
14-
"test:workspace-v2:playwright": "playwright test tests/ui/workspace-v2.asset-manager.spec.js",
14+
"test:workspace-v2:playwright": "playwright test --workers=1 tests/ui/workspace-v2.asset-manager.spec.js tests/playwright/workspace-v2.validation.spec.js tests/playwright/tool-validation",
1515
"test:workspace-v2": "node ./scripts/run-workspace-v2-playwright-gate.mjs",
1616
"test:launch-smoke": "node ./tests/runtime/LaunchSmokeAllEntries.test.mjs",
1717
"test:launch-smoke:games": "node ./tests/runtime/LaunchSmokeAllEntries.test.mjs --games",

playwright.config.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
module.exports = {
2+
timeout: 120000,
23
outputDir: "tests/results",
34
projects: [
45
{
56
name: "ui",
67
testDir: "tests/ui",
78
outputDir: "tests/results/artifacts"
9+
},
10+
{
11+
name: "playwright",
12+
testDir: "tests/playwright",
13+
outputDir: "tests/results/artifacts"
814
}
915
],
1016
reporter: [

scripts/run-workspace-v2-playwright-gate.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ const npmExecPath = typeof process.env.npm_execpath === "string" ? process.env.n
1515
const command = process.execPath;
1616
const args = npmExecPath
1717
? [npmExecPath, "run", "--silent", "test:workspace-v2:playwright"]
18-
: [path.join(repoRoot, "node_modules", "@playwright", "test", "cli.js"), "test", "tests/ui/workspace-v2.asset-manager.spec.js"];
18+
: [
19+
path.join(repoRoot, "node_modules", "@playwright", "test", "cli.js"),
20+
"test",
21+
"tests/ui/workspace-v2.asset-manager.spec.js",
22+
"tests/playwright/workspace-v2.validation.spec.js",
23+
"tests/playwright/tool-validation"
24+
];
1925

2026
const result = spawnSync(command, args, {
2127
cwd: repoRoot,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import http from "node:http";
4+
import { fileURLToPath } from "node:url";
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
const repoRoot = path.resolve(__dirname, "..", "..");
9+
10+
function contentTypeForPath(filePath) {
11+
const extension = path.extname(filePath).toLowerCase();
12+
if (extension === ".html") return "text/html; charset=utf-8";
13+
if (extension === ".js" || extension === ".mjs") return "text/javascript; charset=utf-8";
14+
if (extension === ".json") return "application/json; charset=utf-8";
15+
if (extension === ".css") return "text/css; charset=utf-8";
16+
if (extension === ".svg") return "image/svg+xml";
17+
return "application/octet-stream";
18+
}
19+
20+
export async function startRepoServer() {
21+
const server = http.createServer(async (request, response) => {
22+
try {
23+
const requestUrl = new URL(request.url || "/", "http://127.0.0.1");
24+
const decodedPath = decodeURIComponent(requestUrl.pathname);
25+
const normalizedPath = path.normalize(decodedPath).replace(/^(\.\.[/\\])+/, "");
26+
const absolutePath = path.resolve(repoRoot, `.${normalizedPath}`);
27+
if (!absolutePath.startsWith(repoRoot)) {
28+
response.statusCode = 403;
29+
response.end("Forbidden");
30+
return;
31+
}
32+
let targetPath = absolutePath;
33+
const stat = await fs.stat(targetPath).catch(() => null);
34+
if (stat && stat.isDirectory()) {
35+
targetPath = path.join(targetPath, "index.html");
36+
}
37+
const fileContents = await fs.readFile(targetPath);
38+
response.statusCode = 200;
39+
response.setHeader("Content-Type", contentTypeForPath(targetPath));
40+
response.end(fileContents);
41+
} catch {
42+
response.statusCode = 404;
43+
response.end("Not Found");
44+
}
45+
});
46+
47+
await new Promise((resolve, reject) => {
48+
server.listen(0, "127.0.0.1", () => resolve());
49+
server.on("error", reject);
50+
});
51+
52+
const address = server.address();
53+
if (!address || typeof address === "string") {
54+
throw new Error("Failed to start UI test server.");
55+
}
56+
57+
return {
58+
baseUrl: `http://127.0.0.1:${address.port}`,
59+
close: async () => {
60+
await new Promise((resolve, reject) => {
61+
server.close((error) => {
62+
if (error) reject(error);
63+
else resolve();
64+
});
65+
});
66+
}
67+
};
68+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { test, expect } from "@playwright/test";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs";
6+
import { ctrlTapClick } from "../../helpers/playwrightCtrlTapClick.mjs";
7+
8+
const __filename = fileURLToPath(import.meta.url);
9+
const __dirname = path.dirname(__filename);
10+
const repoRoot = path.resolve(__dirname, "..", "..", "..");
11+
const auditPath = path.join(repoRoot, "docs", "dev", "reports", "tool_completion_audit.md");
12+
const fixtureRoot = path.join(repoRoot, "tests", "fixtures", "v2-tools");
13+
14+
function parseAuditToolList() {
15+
const auditText = fs.readFileSync(auditPath, "utf8");
16+
const toolMatches = [...auditText.matchAll(/-\s+`([^`]+)`/g)]
17+
.map((match) => match[1].trim())
18+
.filter((value) => /^[a-z0-9-]+-v2$/.test(value));
19+
const uniqueTools = [];
20+
toolMatches.forEach((toolId) => {
21+
if (!uniqueTools.includes(toolId)) {
22+
uniqueTools.push(toolId);
23+
}
24+
});
25+
return uniqueTools;
26+
}
27+
28+
function readFixtureSession(toolId) {
29+
const fixturePath = path.join(fixtureRoot, `${toolId}.json`);
30+
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
31+
return {
32+
hostContextId: typeof fixture.hostContextId === "string" ? fixture.hostContextId.trim() : `${toolId}-fixture`,
33+
sessionContext: fixture.sessionContext
34+
};
35+
}
36+
37+
function cloneJson(value) {
38+
return JSON.parse(JSON.stringify(value));
39+
}
40+
41+
function buildWorkspaceManifest(sessionContext, hostContextId) {
42+
return {
43+
documentKind: "workspace-manifest",
44+
schema: "html-js-gaming.project",
45+
version: 1,
46+
id: "workspace-v2-tool-validation",
47+
name: "Workspace V2 Tool Validation",
48+
tools: {
49+
"palette-browser": {
50+
schema: "html-js-gaming.palette",
51+
version: 1,
52+
name: "Workspace Active Palette",
53+
swatches: []
54+
},
55+
"workspace-v2": {
56+
schema: "html-js-gaming.workspace-v2-session/1",
57+
game: {
58+
id: "workspace-v2-tool-validation-game",
59+
name: "Workspace V2 Tool Validation"
60+
},
61+
defaultToolId: "asset-manager-v2",
62+
activeToolId: typeof sessionContext.toolId === "string" ? sessionContext.toolId : "asset-manager-v2",
63+
activeHostContextId: hostContextId,
64+
activeSession: cloneJson(sessionContext),
65+
savedSessions: {}
66+
}
67+
}
68+
};
69+
}
70+
71+
async function importWorkspaceManifest(page, manifest) {
72+
const chooserPromise = page.waitForEvent("filechooser");
73+
await ctrlTapClick(page, page.getByRole("button", { name: "Import Workspace Session JSON" }));
74+
const chooser = await chooserPromise;
75+
await chooser.setFiles({
76+
name: "workspace-v2-tool-validation-import.json",
77+
mimeType: "application/json",
78+
buffer: Buffer.from(JSON.stringify(manifest, null, 2), "utf8")
79+
});
80+
}
81+
82+
async function seedSessionAndOpenTool(page, baseUrl, toolId, hostContextId, sessionContext) {
83+
await page.goto(`${baseUrl}/tools/workspace-v2/index.html`);
84+
await page.evaluate(({ hostContextId: id, sessionContext: payload }) => {
85+
window.sessionStorage.setItem(id, JSON.stringify(payload));
86+
}, { hostContextId, sessionContext });
87+
await page.goto(`${baseUrl}/tools/${toolId}/index.html?hostContextId=${encodeURIComponent(hostContextId)}`);
88+
}
89+
90+
const toolSelectors = {
91+
"asset-manager-v2": {
92+
valid: "#assetBrowserV2ValidState",
93+
invalid: "#assetBrowserV2InvalidState",
94+
readout: "#assetBrowserV2ContractReadout",
95+
validToken: "payloadJson.assetCatalog valid",
96+
invalidToken: "payloadJson.assetCatalog invalid"
97+
},
98+
"palette-manager-v2": {
99+
valid: "#paletteManagerValidState",
100+
invalid: "#paletteManagerInvalidState",
101+
readout: "#paletteManagerContractReadout",
102+
validToken: "payloadJson.paletteDocument valid",
103+
invalidToken: "payloadJson.paletteDocument invalid"
104+
},
105+
"svg-asset-studio-v2": {
106+
valid: "#svgV2ValidState",
107+
invalid: "#svgV2InvalidState",
108+
readout: "#svgV2ToolReadout",
109+
validToken: "payloadJson.vectorAssetDocument valid",
110+
invalidToken: "payloadJson.vectorAssetDocument invalid"
111+
},
112+
"tilemap-studio-v2": {
113+
valid: "#tilemapV2ValidState",
114+
invalid: "#tilemapV2InvalidState",
115+
readout: "#tilemapV2ContractReadout",
116+
validToken: "payloadJson.tileMapDocument valid",
117+
invalidToken: "payloadJson.tileMapDocument invalid"
118+
},
119+
"vector-map-editor-v2": {
120+
valid: "#vectorMapV2ValidState",
121+
invalid: "#vectorMapV2InvalidState",
122+
readout: "#vectorMapV2ContractReadout",
123+
validToken: "payloadJson.vectorMapDocument valid",
124+
invalidToken: "payloadJson.vectorMapDocument invalid"
125+
}
126+
};
127+
128+
const auditTools = parseAuditToolList();
129+
const toolIds = auditTools.filter((toolId) => toolId !== "workspace-v2");
130+
131+
test("@workspace-v2 tool validation list includes all audited tools", async () => {
132+
expect(auditTools).toEqual([
133+
"workspace-v2",
134+
"asset-manager-v2",
135+
"palette-manager-v2",
136+
"svg-asset-studio-v2",
137+
"tilemap-studio-v2",
138+
"vector-map-editor-v2"
139+
]);
140+
});
141+
142+
test("@workspace-v2 valid workspace manifest payloadJson imports", async ({ page }) => {
143+
const server = await startRepoServer();
144+
try {
145+
const fixture = readFixtureSession("asset-manager-v2");
146+
const manifest = buildWorkspaceManifest(fixture.sessionContext, `${fixture.hostContextId}-workspace-valid`);
147+
await page.goto(`${server.baseUrl}/tools/workspace-v2/index.html`);
148+
await importWorkspaceManifest(page, manifest);
149+
await expect(page.locator("#workspaceV2ImportExportStatus")).toHaveText("Workspace session imported.");
150+
} finally {
151+
await server.close();
152+
}
153+
});
154+
155+
test("@workspace-v2 invalid workspace manifest payloadJson is rejected", async ({ page }) => {
156+
const server = await startRepoServer();
157+
try {
158+
const fixture = readFixtureSession("asset-manager-v2");
159+
const manifest = buildWorkspaceManifest(fixture.sessionContext, `${fixture.hostContextId}-workspace-invalid`);
160+
manifest.tools["workspace-v2"].activeSession.payloadJson = null;
161+
await page.goto(`${server.baseUrl}/tools/workspace-v2/index.html`);
162+
await importWorkspaceManifest(page, manifest);
163+
await expect(page.locator("#workspaceV2ImportExportStatus")).toContainText("Import error:");
164+
} finally {
165+
await server.close();
166+
}
167+
});
168+
169+
for (const toolId of toolIds) {
170+
const selectors = toolSelectors[toolId];
171+
if (!selectors) {
172+
continue;
173+
}
174+
175+
test(`@${toolId} valid payloadJson renders`, async ({ page }) => {
176+
const server = await startRepoServer();
177+
try {
178+
const fixture = readFixtureSession(toolId);
179+
await seedSessionAndOpenTool(
180+
page,
181+
server.baseUrl,
182+
toolId,
183+
`${fixture.hostContextId}-valid`,
184+
fixture.sessionContext
185+
);
186+
await expect(page.locator(selectors.valid)).toBeVisible();
187+
await expect(page.locator(selectors.invalid)).toBeHidden();
188+
await expect(page.locator(selectors.readout)).toContainText(selectors.validToken);
189+
} finally {
190+
await server.close();
191+
}
192+
});
193+
194+
test(`@${toolId} invalid payloadJson is rejected without partial render`, async ({ page }) => {
195+
const server = await startRepoServer();
196+
try {
197+
const fixture = readFixtureSession(toolId);
198+
const invalidSession = cloneJson(fixture.sessionContext);
199+
invalidSession.payloadJson = null;
200+
await seedSessionAndOpenTool(
201+
page,
202+
server.baseUrl,
203+
toolId,
204+
`${fixture.hostContextId}-invalid`,
205+
invalidSession
206+
);
207+
await expect(page.locator(selectors.invalid)).toBeVisible();
208+
await expect(page.locator(selectors.valid)).toBeHidden();
209+
await expect(page.locator(selectors.readout)).toContainText(selectors.invalidToken);
210+
} finally {
211+
await server.close();
212+
}
213+
});
214+
}

0 commit comments

Comments
 (0)