Skip to content

Commit ad01134

Browse files
author
DavidQ
committed
Add runtime snapshot export for V2 tools (URL + session + context) with executable validation - PR 11.228
1 parent 7ed64eb commit ad01134

9 files changed

Lines changed: 483 additions & 1 deletion

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# PR_11_228 Report - V2 Tool State Snapshot (Export for Debug)
2+
3+
## Files Changed
4+
- `tools/asset-browser-v2/index.js`
5+
- `tools/palette-manager-v2/index.js`
6+
- `tools/svg-asset-studio-v2/index.js`
7+
- `tools/tilemap-studio-v2/index.js`
8+
- `tools/vector-map-editor-v2/index.js`
9+
- `tools/workspace-v2/index.html`
10+
- `tools/workspace-v2/index.js`
11+
- `tests/runtime/V2Snapshot.test.mjs`
12+
- `docs/dev/reports/PR_11_228_report.md`
13+
14+
## Snapshot Hooks Added
15+
Added snapshot hook pattern to all V2 tools:
16+
- `buildRuntimeSnapshot()`
17+
- `registerSnapshotHook()`
18+
- `window.__v2RuntimeSnapshot = () => this.buildRuntimeSnapshot();`
19+
20+
Snapshot content shape:
21+
```json
22+
{
23+
"tool": "<tool-id>",
24+
"url": "<full URL>",
25+
"hostContextId": "<id>",
26+
"session": { "...payload..." }
27+
}
28+
```
29+
30+
Safety behavior:
31+
- malformed session JSON does not crash snapshot generation
32+
- malformed parse is represented by `session: null` with `sessionError`
33+
- no mutation of runtime/session state
34+
35+
## Workspace Export
36+
Workspace V2 now includes:
37+
- `Export Runtime Snapshot` button
38+
- snapshot output region in diagnostics panel
39+
- collection logic that reads URL + active `hostContextId` + `sessionStorage` payload safely
40+
41+
## Snapshot Samples
42+
From `tmp/v2-snapshot-results.json`:
43+
44+
Tool snapshot sample:
45+
```json
46+
{
47+
"tool": "tilemap-studio-v2",
48+
"url": "https://example.test/tools/tilemap-studio-v2/index.html?hostContextId=snapshot-host-1&view=debug",
49+
"hostContextId": "snapshot-host-1",
50+
"session": {
51+
"version": "v2",
52+
"toolId": "tilemap-studio-v2"
53+
}
54+
}
55+
```
56+
57+
Workspace snapshot sample:
58+
```json
59+
{
60+
"tool": "workspace-v2",
61+
"url": "https://example.test/tools/workspace-v2/index.html?hostContextId=snapshot-host-1",
62+
"hostContextId": "snapshot-host-1",
63+
"session": {
64+
"version": "v2",
65+
"toolId": "tilemap-studio-v2"
66+
}
67+
}
68+
```
69+
70+
## Validation Results
71+
Commands run:
72+
1. `node --check tests/runtime/V2Snapshot.test.mjs`
73+
Result: **PASS**
74+
2. `node tests/runtime/V2Snapshot.test.mjs`
75+
Result: **PASS** (writes `tmp/v2-snapshot-results.json`)
76+
3. `node --check tools/workspace-v2/index.js`
77+
Result: **PASS**
78+
79+
## No Fallback Confirmation
80+
- No fallback/default/demo session data introduced.
81+
- No rendering logic changes introduced.
82+
- Snapshot export is read-only and deterministic.

tests/runtime/V2Snapshot.test.mjs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { execFileSync } from "node:child_process";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const repoRoot = path.resolve(__dirname, "..", "..");
10+
const toolsRoot = path.join(repoRoot, "tools");
11+
const workspaceHtmlPath = path.join(repoRoot, "tools", "workspace-v2", "index.html");
12+
const workspaceJsPath = path.join(repoRoot, "tools", "workspace-v2", "index.js");
13+
const resultsPath = path.join(repoRoot, "tmp", "v2-snapshot-results.json");
14+
15+
const TOOLS = [
16+
"asset-browser-v2",
17+
"palette-manager-v2",
18+
"svg-asset-studio-v2",
19+
"tilemap-studio-v2",
20+
"vector-map-editor-v2"
21+
];
22+
23+
class MemorySessionStorage {
24+
constructor() {
25+
this.values = new Map();
26+
}
27+
28+
setItem(key, value) {
29+
this.values.set(String(key), String(value));
30+
}
31+
32+
getItem(key) {
33+
if (!this.values.has(String(key))) return null;
34+
return this.values.get(String(key));
35+
}
36+
}
37+
38+
function readText(filePath) {
39+
return fs.readFileSync(filePath, "utf8");
40+
}
41+
42+
function checkJsSyntax(jsPath) {
43+
try {
44+
execFileSync(process.execPath, ["--check", jsPath], {
45+
cwd: repoRoot,
46+
stdio: ["ignore", "pipe", "pipe"]
47+
});
48+
return { syntaxValid: true, syntaxError: "" };
49+
} catch (error) {
50+
return {
51+
syntaxValid: false,
52+
syntaxError: (error?.stderr || error?.stdout || error?.message || "").toString().trim()
53+
};
54+
}
55+
}
56+
57+
function buildToolSnapshot(toolId, url, hostContextId, sessionStorageLike) {
58+
const serializedSession = hostContextId ? sessionStorageLike.getItem(hostContextId) : null;
59+
let parsedSession = null;
60+
let sessionError = "";
61+
if (typeof serializedSession === "string") {
62+
try {
63+
parsedSession = JSON.parse(serializedSession);
64+
} catch (error) {
65+
sessionError = error instanceof Error ? error.message : "unknown error";
66+
}
67+
}
68+
return {
69+
tool: toolId,
70+
url,
71+
hostContextId,
72+
session: parsedSession,
73+
sessionError
74+
};
75+
}
76+
77+
function buildWorkspaceSnapshot(url, sessionStorageLike) {
78+
const params = new URL(url).searchParams;
79+
const hostContextId = typeof params.get("hostContextId") === "string" ? params.get("hostContextId").trim() : "";
80+
let parsedSession = null;
81+
if (hostContextId) {
82+
const raw = sessionStorageLike.getItem(hostContextId);
83+
if (typeof raw === "string") {
84+
try {
85+
parsedSession = JSON.parse(raw);
86+
} catch {
87+
parsedSession = null;
88+
}
89+
}
90+
}
91+
return {
92+
tool: "workspace-v2",
93+
url,
94+
hostContextId,
95+
session: parsedSession
96+
};
97+
}
98+
99+
function validateToolHook(toolId) {
100+
const jsPath = path.join(toolsRoot, toolId, "index.js");
101+
const jsExists = fs.existsSync(jsPath);
102+
const jsText = jsExists ? readText(jsPath) : "";
103+
const { syntaxValid, syntaxError } = checkJsSyntax(jsPath);
104+
const failures = [];
105+
106+
const hasBuildSnapshot = jsText.includes("buildRuntimeSnapshot()");
107+
const hasRegisterHook = jsText.includes("registerSnapshotHook()");
108+
const hasWindowHook = jsText.includes("window.__v2RuntimeSnapshot = () => this.buildRuntimeSnapshot();");
109+
const hasToolField = jsText.includes(`tool: "${toolId}"`);
110+
const hasUrlField = jsText.includes("url: window.location.href");
111+
const hasHostContextIdField = jsText.includes("hostContextId: this.urlState.hostContextId");
112+
const hasSessionField = jsText.includes("session: parsedSession");
113+
114+
if (!jsExists) failures.push("Missing tool index.js.");
115+
if (!syntaxValid) failures.push("Tool index.js failed syntax check.");
116+
if (!hasBuildSnapshot) failures.push("Missing buildRuntimeSnapshot() hook.");
117+
if (!hasRegisterHook) failures.push("Missing registerSnapshotHook() hook.");
118+
if (!hasWindowHook) failures.push("Missing window.__v2RuntimeSnapshot registration.");
119+
if (!hasToolField) failures.push("Snapshot is missing tool field.");
120+
if (!hasUrlField) failures.push("Snapshot is missing url field.");
121+
if (!hasHostContextIdField) failures.push("Snapshot is missing hostContextId field.");
122+
if (!hasSessionField) failures.push("Snapshot is missing session field.");
123+
124+
return {
125+
tool: toolId,
126+
jsPath: path.relative(repoRoot, jsPath).replace(/\\/g, "/"),
127+
jsExists,
128+
syntaxValid,
129+
syntaxError,
130+
hasBuildSnapshot,
131+
hasRegisterHook,
132+
hasWindowHook,
133+
hasToolField,
134+
hasUrlField,
135+
hasHostContextIdField,
136+
hasSessionField,
137+
failures
138+
};
139+
}
140+
141+
export function run() {
142+
const failures = [];
143+
const sessionStorageLike = new MemorySessionStorage();
144+
const sampleHostContextId = "snapshot-host-1";
145+
const sampleSession = {
146+
version: "v2",
147+
toolId: "tilemap-studio-v2",
148+
payloadJson: {
149+
tileMapDocument: {
150+
map: { name: "Snapshot Fixture", width: 2, height: 2 },
151+
layers: [{ name: "Ground", kind: "tiles", data: [[1, 1], [1, 1]] }]
152+
}
153+
}
154+
};
155+
sessionStorageLike.setItem(sampleHostContextId, JSON.stringify(sampleSession));
156+
157+
const toolUrl = `https://example.test/tools/tilemap-studio-v2/index.html?hostContextId=${encodeURIComponent(sampleHostContextId)}&view=debug`;
158+
const toolSnapshot = buildToolSnapshot("tilemap-studio-v2", toolUrl, sampleHostContextId, sessionStorageLike);
159+
const workspaceUrl = `https://example.test/tools/workspace-v2/index.html?hostContextId=${encodeURIComponent(sampleHostContextId)}`;
160+
const workspaceSnapshot = buildWorkspaceSnapshot(workspaceUrl, sessionStorageLike);
161+
162+
if (toolSnapshot.tool !== "tilemap-studio-v2") failures.push("Tool snapshot missing correct tool id.");
163+
if (!toolSnapshot.url.includes("hostContextId=")) failures.push("Tool snapshot missing full URL.");
164+
if (toolSnapshot.hostContextId !== sampleHostContextId) failures.push("Tool snapshot hostContextId mismatch.");
165+
if (JSON.stringify(toolSnapshot.session) !== JSON.stringify(sampleSession)) failures.push("Tool snapshot session payload mismatch.");
166+
if (workspaceSnapshot.tool !== "workspace-v2") failures.push("Workspace snapshot missing workspace-v2 tool id.");
167+
if (workspaceSnapshot.hostContextId !== sampleHostContextId) failures.push("Workspace snapshot hostContextId mismatch.");
168+
if (JSON.stringify(workspaceSnapshot.session) !== JSON.stringify(sampleSession)) failures.push("Workspace snapshot session payload mismatch.");
169+
170+
const malformedHostContextId = "snapshot-host-malformed";
171+
sessionStorageLike.setItem(malformedHostContextId, "{bad-json");
172+
const malformedSnapshot = buildToolSnapshot("asset-browser-v2", `https://example.test/tools/asset-browser-v2/index.html?hostContextId=${malformedHostContextId}`, malformedHostContextId, sessionStorageLike);
173+
if (!malformedSnapshot.sessionError) failures.push("Malformed snapshot should include sessionError.");
174+
175+
const toolRows = TOOLS.map(validateToolHook);
176+
toolRows.forEach((row) => {
177+
row.failures.forEach((entry) => failures.push(`${row.tool}: ${entry}`));
178+
});
179+
180+
const workspaceHtmlExists = fs.existsSync(workspaceHtmlPath);
181+
const workspaceJsExists = fs.existsSync(workspaceJsPath);
182+
const workspaceHtmlText = workspaceHtmlExists ? readText(workspaceHtmlPath) : "";
183+
const workspaceJsText = workspaceJsExists ? readText(workspaceJsPath) : "";
184+
const workspaceSyntax = checkJsSyntax(workspaceJsPath);
185+
const workspaceHasButton = workspaceHtmlText.includes("workspaceV2ExportSnapshotButton");
186+
const workspaceHasOutput = workspaceHtmlText.includes("workspaceV2SnapshotOutput");
187+
const workspaceHasBuild = workspaceJsText.includes("buildRuntimeSnapshot()");
188+
const workspaceHasExport = workspaceJsText.includes("exportRuntimeSnapshot()");
189+
const workspaceHasWindowHook = workspaceJsText.includes("window.__v2RuntimeSnapshot = () => this.buildRuntimeSnapshot();");
190+
191+
if (!workspaceHtmlExists) failures.push("Missing workspace-v2/index.html.");
192+
if (!workspaceJsExists) failures.push("Missing workspace-v2/index.js.");
193+
if (!workspaceSyntax.syntaxValid) failures.push("workspace-v2/index.js failed syntax check.");
194+
if (!workspaceHasButton) failures.push("Workspace missing Export Runtime Snapshot button.");
195+
if (!workspaceHasOutput) failures.push("Workspace missing snapshot output region.");
196+
if (!workspaceHasBuild) failures.push("Workspace missing buildRuntimeSnapshot().");
197+
if (!workspaceHasExport) failures.push("Workspace missing exportRuntimeSnapshot().");
198+
if (!workspaceHasWindowHook) failures.push("Workspace missing snapshot hook registration.");
199+
200+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
201+
fs.writeFileSync(resultsPath, `${JSON.stringify({
202+
generatedAt: new Date().toISOString(),
203+
failures,
204+
snapshots: {
205+
toolSnapshot,
206+
workspaceSnapshot,
207+
malformedSnapshot
208+
},
209+
toolRows,
210+
workspaceChecks: {
211+
workspaceHtmlExists,
212+
workspaceJsExists,
213+
syntaxValid: workspaceSyntax.syntaxValid,
214+
syntaxError: workspaceSyntax.syntaxError,
215+
workspaceHasButton,
216+
workspaceHasOutput,
217+
workspaceHasBuild,
218+
workspaceHasExport,
219+
workspaceHasWindowHook
220+
}
221+
}, null, 2)}\n`, "utf8");
222+
223+
console.log(`v2 snapshot results: ${resultsPath}`);
224+
assert.equal(failures.length, 0, `V2 snapshot failures: ${failures.join(" | ")}`);
225+
return { failures, toolSnapshot, workspaceSnapshot };
226+
}
227+
228+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
229+
try {
230+
const summary = run();
231+
console.log(JSON.stringify(summary, null, 2));
232+
} catch (error) {
233+
console.error(error);
234+
process.exitCode = 1;
235+
}
236+
}

tools/asset-browser-v2/index.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class AssetBrowserV2 {
1313
document.getElementById("assetBrowserV2BackButton").addEventListener("click", this.goBack);
1414
document.getElementById("assetBrowserV2OpenSvgAssetStudioV2Button").addEventListener("click", this.openSvgAssetStudioV2);
1515
this.renderNavigation();
16+
this.registerSnapshotHook();
1617
this.readSession();
1718
}
1819

@@ -91,6 +92,30 @@ class AssetBrowserV2 {
9192
};
9293
}
9394

95+
buildRuntimeSnapshot() {
96+
const serializedSession = this.urlState.hostContextId ? window.sessionStorage.getItem(this.urlState.hostContextId) : null;
97+
let parsedSession = null;
98+
let sessionError = "";
99+
if (typeof serializedSession === "string") {
100+
try {
101+
parsedSession = JSON.parse(serializedSession);
102+
} catch (error) {
103+
sessionError = error instanceof Error ? error.message : "unknown error";
104+
}
105+
}
106+
return {
107+
tool: "asset-browser-v2",
108+
url: window.location.href,
109+
hostContextId: this.urlState.hostContextId,
110+
session: parsedSession,
111+
sessionError
112+
};
113+
}
114+
115+
registerSnapshotHook() {
116+
window.__v2RuntimeSnapshot = () => this.buildRuntimeSnapshot();
117+
}
118+
94119
logStructuredError(type, message, details) {
95120
console.error({
96121
tool: "asset-browser-v2",

tools/palette-manager-v2/index.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class PaletteManagerV2 {
1313
document.getElementById("paletteManagerBackButton").addEventListener("click", this.goBack);
1414
document.getElementById("paletteManagerOpenVectorMapEditorV2Button").addEventListener("click", this.openVectorMapEditorV2);
1515
this.renderNavigation();
16+
this.registerSnapshotHook();
1617
this.readSession();
1718
}
1819

@@ -91,6 +92,30 @@ class PaletteManagerV2 {
9192
};
9293
}
9394

95+
buildRuntimeSnapshot() {
96+
const serializedSession = this.urlState.hostContextId ? window.sessionStorage.getItem(this.urlState.hostContextId) : null;
97+
let parsedSession = null;
98+
let sessionError = "";
99+
if (typeof serializedSession === "string") {
100+
try {
101+
parsedSession = JSON.parse(serializedSession);
102+
} catch (error) {
103+
sessionError = error instanceof Error ? error.message : "unknown error";
104+
}
105+
}
106+
return {
107+
tool: "palette-manager-v2",
108+
url: window.location.href,
109+
hostContextId: this.urlState.hostContextId,
110+
session: parsedSession,
111+
sessionError
112+
};
113+
}
114+
115+
registerSnapshotHook() {
116+
window.__v2RuntimeSnapshot = () => this.buildRuntimeSnapshot();
117+
}
118+
94119
logStructuredError(type, message, details) {
95120
console.error({
96121
tool: "palette-manager-v2",

0 commit comments

Comments
 (0)