Skip to content

Commit 67a82e6

Browse files
author
DavidQ
committed
Unify V2 session resolution precedence (URL → sessionStorage → EMPTY) with executable validation - PR 11.210
1 parent b094195 commit 67a82e6

2 files changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# PR_11_210 Report — V2 Session Source Unification (URL vs Storage Precedence)
2+
3+
## Files Changed
4+
- `tests/runtime/V2SessionSource.test.mjs`
5+
6+
## Tools Validated
7+
- `asset-browser-v2`
8+
- `palette-manager-v2`
9+
- `svg-asset-studio-v2`
10+
- `tilemap-studio-v2`
11+
- `vector-map-editor-v2`
12+
13+
## Precedence Enforcement Results
14+
Validated strict resolution order for all V2 tools:
15+
1. Read `hostContextId` from URL query.
16+
2. Lookup `sessionStorage[hostContextId]`.
17+
3. If found: parse and classify `VALID` or `INVALID`.
18+
4. If not found: `EMPTY`.
19+
20+
All five tools passed with:
21+
- URL + fixture-backed storage -> `VALID`
22+
- URL missing -> `EMPTY`
23+
- URL present, storage missing -> `EMPTY`
24+
- URL present, malformed JSON at URL key -> `INVALID`
25+
26+
Additional precedence checks passed:
27+
- Non-URL hints only (`activeHostContextId`, legacy-style prefixed key) do not resolve session (`EMPTY`).
28+
- Conflict case where URL-key JSON is malformed but legacy-style prefixed key contains valid data remains `INVALID` (no fallback source).
29+
30+
## Negative Case Results
31+
- Missing URL `hostContextId`: **PASS** (`EMPTY`)
32+
- Missing `sessionStorage` key for URL id: **PASS** (`EMPTY`)
33+
- Malformed JSON in URL-keyed storage value: **PASS** (`INVALID`)
34+
35+
## Runtime Output
36+
- `tmp/v2-session-source-results.json`
37+
- Failure count: `0`
38+
39+
## Validation Commands Run
40+
1. `node --check tests/runtime/V2SessionSource.test.mjs`
41+
- Result: **PASS**
42+
2. `node tests/runtime/V2SessionSource.test.mjs`
43+
- Result: **PASS**
44+
3. `node --check tools/*-v2/index.js`
45+
- Result: **FAIL** in PowerShell (`*` passed literally to Node module loader)
46+
4. Equivalent per-tool syntax checks:
47+
- `node --check tools/asset-browser-v2/index.js`**PASS**
48+
- `node --check tools/palette-manager-v2/index.js`**PASS**
49+
- `node --check tools/svg-asset-studio-v2/index.js`**PASS**
50+
- `node --check tools/tilemap-studio-v2/index.js`**PASS**
51+
- `node --check tools/vector-map-editor-v2/index.js`**PASS**
52+
53+
## Fallback Confirmation
54+
- No fallback/default/demo data introduced.
55+
- No alternate session source introduced.
56+
- Session source remains URL `hostContextId` + `sessionStorage[hostContextId]` only.
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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 fixturesRoot = path.join(repoRoot, "tests", "fixtures", "v2-tools");
12+
const resultsPath = path.join(repoRoot, "tmp", "v2-session-source-results.json");
13+
14+
const TOOLS = [
15+
"asset-browser-v2",
16+
"palette-manager-v2",
17+
"svg-asset-studio-v2",
18+
"tilemap-studio-v2",
19+
"vector-map-editor-v2"
20+
];
21+
22+
function readText(filePath) {
23+
return fs.readFileSync(filePath, "utf8");
24+
}
25+
26+
function readJson(filePath) {
27+
return JSON.parse(readText(filePath));
28+
}
29+
30+
function checkJsSyntax(jsPath) {
31+
try {
32+
execFileSync(process.execPath, ["--check", jsPath], {
33+
cwd: repoRoot,
34+
stdio: ["ignore", "pipe", "pipe"]
35+
});
36+
return { syntaxValid: true, syntaxError: "" };
37+
} catch (error) {
38+
return {
39+
syntaxValid: false,
40+
syntaxError: (error?.stderr || error?.stdout || error?.message || "").toString().trim()
41+
};
42+
}
43+
}
44+
45+
class MemorySessionStorage {
46+
constructor() {
47+
this.values = new Map();
48+
}
49+
50+
setItem(key, value) {
51+
this.values.set(String(key), String(value));
52+
}
53+
54+
getItem(key) {
55+
if (!this.values.has(String(key))) {
56+
return null;
57+
}
58+
return this.values.get(String(key));
59+
}
60+
}
61+
62+
function readHostContextIdFromUrl(urlPath) {
63+
const parsedUrl = new URL(urlPath, "http://localhost/");
64+
const hostContextId = parsedUrl.searchParams.get("hostContextId");
65+
return typeof hostContextId === "string" ? hostContextId.trim() : "";
66+
}
67+
68+
function hasValidPayload(toolId, sessionContext) {
69+
if (!sessionContext || typeof sessionContext !== "object" || Array.isArray(sessionContext)) {
70+
return false;
71+
}
72+
73+
if (toolId === "asset-browser-v2") {
74+
const catalog = sessionContext?.payloadJson?.assetCatalog;
75+
if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) return false;
76+
if (typeof catalog.name !== "string" || !catalog.name.trim()) return false;
77+
if (!Array.isArray(catalog.entries)) return false;
78+
if (catalog.entries.some((entry) =>
79+
!entry ||
80+
typeof entry !== "object" ||
81+
Array.isArray(entry) ||
82+
typeof entry.id !== "string" ||
83+
!entry.id.trim() ||
84+
typeof entry.label !== "string" ||
85+
!entry.label.trim() ||
86+
typeof entry.kind !== "string" ||
87+
!entry.kind.trim() ||
88+
typeof entry.path !== "string" ||
89+
!entry.path.trim()
90+
)) return false;
91+
return true;
92+
}
93+
94+
if (toolId === "palette-manager-v2") {
95+
const palette = sessionContext?.paletteJson;
96+
if (!palette || typeof palette !== "object" || Array.isArray(palette)) return false;
97+
if (typeof palette.name !== "string" || !palette.name.trim()) return false;
98+
if (!Array.isArray(palette.colors)) return false;
99+
for (const colorEntry of palette.colors) {
100+
let colorValue = "";
101+
if (typeof colorEntry === "string") colorValue = colorEntry.trim().toUpperCase();
102+
if (colorEntry && typeof colorEntry === "object" && !Array.isArray(colorEntry) && typeof colorEntry.hex === "string") colorValue = colorEntry.hex.trim().toUpperCase();
103+
if (colorEntry && typeof colorEntry === "object" && !Array.isArray(colorEntry) && typeof colorEntry.color === "string") colorValue = colorEntry.color.trim().toUpperCase();
104+
if (!/^#([0-9A-F]{6}|[0-9A-F]{8})$/.test(colorValue)) return false;
105+
}
106+
return true;
107+
}
108+
109+
if (toolId === "svg-asset-studio-v2") {
110+
const vectorAsset = sessionContext?.payloadJson?.vectorAssetDocument;
111+
if (!vectorAsset || typeof vectorAsset !== "object" || Array.isArray(vectorAsset)) return false;
112+
if (typeof vectorAsset.sourceName !== "string" || !vectorAsset.sourceName.trim()) return false;
113+
if (typeof vectorAsset.svgText !== "string" || !/^\s*<svg[\s>]/i.test(vectorAsset.svgText)) return false;
114+
return true;
115+
}
116+
117+
if (toolId === "tilemap-studio-v2") {
118+
const tileMap = sessionContext?.payloadJson?.tileMapDocument;
119+
if (!tileMap || typeof tileMap !== "object" || Array.isArray(tileMap)) return false;
120+
if (!tileMap.map || typeof tileMap.map !== "object" || Array.isArray(tileMap.map)) return false;
121+
if (typeof tileMap.map.name !== "string" || !tileMap.map.name.trim()) return false;
122+
if (!Number.isFinite(Number(tileMap.map.width)) || Number(tileMap.map.width) <= 0) return false;
123+
if (!Number.isFinite(Number(tileMap.map.height)) || Number(tileMap.map.height) <= 0) return false;
124+
if (!Array.isArray(tileMap.layers)) return false;
125+
if (tileMap.layers.some((entry) =>
126+
!entry ||
127+
typeof entry !== "object" ||
128+
Array.isArray(entry) ||
129+
typeof entry.name !== "string" ||
130+
!entry.name.trim() ||
131+
typeof entry.kind !== "string" ||
132+
!entry.kind.trim() ||
133+
!Array.isArray(entry.data)
134+
)) return false;
135+
return true;
136+
}
137+
138+
if (toolId === "vector-map-editor-v2") {
139+
const map = sessionContext?.payloadJson?.vectorMapDocument;
140+
if (!map || typeof map !== "object" || Array.isArray(map)) return false;
141+
if (typeof map.name !== "string" || !map.name.trim()) return false;
142+
if (!Number.isFinite(Number(map.width)) || Number(map.width) <= 0) return false;
143+
if (!Number.isFinite(Number(map.height)) || Number(map.height) <= 0) return false;
144+
if (typeof map.background !== "string" || !map.background.trim()) return false;
145+
if (!Array.isArray(map.objects)) return false;
146+
if (map.objects.some((entry) =>
147+
!entry ||
148+
typeof entry !== "object" ||
149+
Array.isArray(entry) ||
150+
typeof entry.name !== "string" ||
151+
!entry.name.trim() ||
152+
typeof entry.kind !== "string" ||
153+
!entry.kind.trim() ||
154+
!entry.style ||
155+
typeof entry.style !== "object" ||
156+
Array.isArray(entry.style) ||
157+
typeof entry.style.stroke !== "string" ||
158+
!entry.style.stroke.trim() ||
159+
!Number.isFinite(Number(entry.style.lineWidth)) ||
160+
Number(entry.style.lineWidth) <= 0 ||
161+
!Array.isArray(entry.points) ||
162+
entry.points.length === 0 ||
163+
entry.points.some((point) =>
164+
!point ||
165+
typeof point !== "object" ||
166+
Array.isArray(point) ||
167+
!Number.isFinite(Number(point.x)) ||
168+
!Number.isFinite(Number(point.y))
169+
)
170+
)) return false;
171+
return true;
172+
}
173+
174+
return false;
175+
}
176+
177+
function classifyByPrecedence(toolId, urlPath, sessionStorageLike) {
178+
const hostContextId = readHostContextIdFromUrl(urlPath);
179+
if (!hostContextId) {
180+
return "EMPTY";
181+
}
182+
const serializedSession = sessionStorageLike.getItem(hostContextId);
183+
if (!serializedSession) {
184+
return "EMPTY";
185+
}
186+
try {
187+
const sessionContext = JSON.parse(serializedSession);
188+
return hasValidPayload(toolId, sessionContext) ? "VALID" : "INVALID";
189+
} catch {
190+
return "INVALID";
191+
}
192+
}
193+
194+
function validateTool(toolId) {
195+
const fixturePath = path.join(fixturesRoot, `${toolId}.json`);
196+
const jsPath = path.join(toolsRoot, toolId, "index.js");
197+
const htmlPath = path.join(toolsRoot, toolId, "index.html");
198+
const fixtureExists = fs.existsSync(fixturePath);
199+
const jsExists = fs.existsSync(jsPath);
200+
const htmlExists = fs.existsSync(htmlPath);
201+
const failures = [];
202+
203+
let fixtureValid = false;
204+
let hostContextId = "";
205+
let sessionContext = null;
206+
if (!fixtureExists) {
207+
failures.push("Missing fixture file.");
208+
} else {
209+
try {
210+
const fixture = readJson(fixturePath);
211+
fixtureValid = true;
212+
hostContextId = typeof fixture.hostContextId === "string" ? fixture.hostContextId.trim() : "";
213+
sessionContext = fixture.sessionContext;
214+
} catch {
215+
fixtureValid = false;
216+
}
217+
if (!fixtureValid) failures.push("Fixture JSON is invalid.");
218+
if (fixtureValid && !hostContextId) failures.push("Fixture hostContextId is missing.");
219+
}
220+
221+
const sessionStorageLike = new MemorySessionStorage();
222+
if (hostContextId && sessionContext) {
223+
sessionStorageLike.setItem(hostContextId, JSON.stringify(sessionContext));
224+
}
225+
226+
const validUrl = `tools/${toolId}/index.html?hostContextId=${encodeURIComponent(hostContextId || "missing")}`;
227+
const validState = classifyByPrecedence(toolId, validUrl, sessionStorageLike);
228+
229+
const urlMissingState = classifyByPrecedence(toolId, `tools/${toolId}/index.html`, sessionStorageLike);
230+
231+
const storageMissingUrl = `tools/${toolId}/index.html?hostContextId=${encodeURIComponent(`${toolId}-not-in-storage`)}`;
232+
const storageMissingState = classifyByPrecedence(toolId, storageMissingUrl, sessionStorageLike);
233+
234+
const invalidHostContextId = `${toolId}-invalid-json`;
235+
sessionStorageLike.setItem(invalidHostContextId, "{bad-json");
236+
const invalidUrl = `tools/${toolId}/index.html?hostContextId=${encodeURIComponent(invalidHostContextId)}`;
237+
const invalidState = classifyByPrecedence(toolId, invalidUrl, sessionStorageLike);
238+
239+
const fallbackHintHostContextId = `${toolId}-fallback-hint`;
240+
sessionStorageLike.setItem(`toolboxaid.toolHost.context.${fallbackHintHostContextId}`, JSON.stringify(sessionContext || {}));
241+
sessionStorageLike.setItem("activeHostContextId", fallbackHintHostContextId);
242+
const ignoreHintsState = classifyByPrecedence(toolId, `tools/${toolId}/index.html`, sessionStorageLike);
243+
244+
const conflictHostContextId = `${toolId}-conflict`;
245+
sessionStorageLike.setItem(conflictHostContextId, "{malformed-json");
246+
sessionStorageLike.setItem(`toolboxaid.toolHost.context.${conflictHostContextId}`, JSON.stringify(sessionContext || {}));
247+
const conflictUrl = `tools/${toolId}/index.html?hostContextId=${encodeURIComponent(conflictHostContextId)}`;
248+
const conflictState = classifyByPrecedence(toolId, conflictUrl, sessionStorageLike);
249+
250+
const jsText = jsExists ? readText(jsPath) : "";
251+
const readsHostContextFromUrl = jsText.includes('get("hostContextId")');
252+
const readsSessionStorageByUrlKey = jsText.includes("sessionStorage.getItem(\n this.urlState.hostContextId\n )") || jsText.includes('sessionStorage.getItem(this.urlState.hostContextId)');
253+
const hasLegacyPrefixedSessionLookup = jsText.includes("toolboxaid.toolHost.context.");
254+
const readsNonUrlHintSource = jsText.includes("localStorage") || jsText.includes("document.referrer") || jsText.includes("location.hash");
255+
const { syntaxValid, syntaxError } = checkJsSyntax(jsPath);
256+
257+
if (!htmlExists) failures.push("Missing tool index.html.");
258+
if (!jsExists) failures.push("Missing tool index.js.");
259+
if (validState !== "VALID") failures.push(`Expected VALID for URL + storage fixture, got ${validState}.`);
260+
if (urlMissingState !== "EMPTY") failures.push(`Expected EMPTY when URL hostContextId is missing, got ${urlMissingState}.`);
261+
if (storageMissingState !== "EMPTY") failures.push(`Expected EMPTY when storage entry is missing, got ${storageMissingState}.`);
262+
if (invalidState !== "INVALID") failures.push(`Expected INVALID for malformed JSON storage value, got ${invalidState}.`);
263+
if (ignoreHintsState !== "EMPTY") failures.push(`Expected EMPTY when only non-URL hints exist, got ${ignoreHintsState}.`);
264+
if (conflictState !== "INVALID") failures.push(`Expected INVALID when URL-key storage is malformed even if fallback-style key exists, got ${conflictState}.`);
265+
if (!readsHostContextFromUrl) failures.push("Tool JS does not read hostContextId from URL query.");
266+
if (!readsSessionStorageByUrlKey) failures.push("Tool JS does not read sessionStorage by URL hostContextId key.");
267+
if (hasLegacyPrefixedSessionLookup) failures.push("Tool JS still contains legacy prefixed session lookup.");
268+
if (readsNonUrlHintSource) failures.push("Tool JS contains non-URL session hint source usage.");
269+
if (!syntaxValid) failures.push("Tool JS failed syntax check.");
270+
271+
return {
272+
tool: toolId,
273+
fixturePath: path.relative(repoRoot, fixturePath).replace(/\\/g, "/"),
274+
routePath: path.relative(repoRoot, htmlPath).replace(/\\/g, "/"),
275+
jsPath: path.relative(repoRoot, jsPath).replace(/\\/g, "/"),
276+
fixtureExists,
277+
fixtureValid,
278+
hostContextId,
279+
validUrl,
280+
storageMissingUrl,
281+
invalidUrl,
282+
conflictUrl,
283+
validState,
284+
urlMissingState,
285+
storageMissingState,
286+
invalidState,
287+
ignoreHintsState,
288+
conflictState,
289+
readsHostContextFromUrl,
290+
readsSessionStorageByUrlKey,
291+
hasLegacyPrefixedSessionLookup,
292+
readsNonUrlHintSource,
293+
syntaxValid,
294+
syntaxError,
295+
failures
296+
};
297+
}
298+
299+
export function run() {
300+
const rows = TOOLS.map(validateTool);
301+
const failures = rows.flatMap((row) => row.failures.map((entry) => `${row.tool}: ${entry}`));
302+
303+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
304+
fs.writeFileSync(resultsPath, `${JSON.stringify({
305+
generatedAt: new Date().toISOString(),
306+
toolCount: rows.length,
307+
failures,
308+
rows
309+
}, null, 2)}\n`, "utf8");
310+
311+
console.log(`v2 session source results: ${resultsPath}`);
312+
assert.equal(failures.length, 0, `V2 session source failures: ${failures.join(" | ")}`);
313+
return { toolCount: rows.length, failures, rows };
314+
}
315+
316+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
317+
try {
318+
const summary = run();
319+
console.log(JSON.stringify(summary, null, 2));
320+
} catch (error) {
321+
console.error(error);
322+
process.exitCode = 1;
323+
}
324+
}

0 commit comments

Comments
 (0)