Skip to content

Commit 3dda241

Browse files
author
DavidQ
committed
Add Workspace V2 error viewer for structured logs with executable validation - PR 11.222
1 parent 8f6c801 commit 3dda241

4 files changed

Lines changed: 448 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# PR_11_222 Report - V2 Error Viewer (Workspace V2)
2+
3+
## Files Changed
4+
- `tools/workspace-v2/index.html`
5+
- `tools/workspace-v2/index.js`
6+
- `tests/runtime/V2ErrorViewer.test.mjs`
7+
- `docs/dev/reports/PR_11_222_report.md`
8+
9+
## Viewer Behavior
10+
Workspace V2 now includes an **Error Viewer** panel that reads structured logs from:
11+
- localStorage key: `v2-error-logs`
12+
13+
Viewer features:
14+
- displays log entries grouped by `tool`
15+
- shows `type` (`EMPTY | INVALID | RUNTIME`)
16+
- shows `message`
17+
- shows `details` JSON
18+
- shows `timestamp`
19+
- supports `Refresh Error Logs`
20+
- supports `Clear Error Logs` (sets storage key to `[]`)
21+
- shows visible empty state when no logs exist
22+
23+
Invalid entry handling:
24+
- non-array storage values or malformed JSON are ignored with `console.warn`
25+
- invalid rows are filtered out and ignored with `console.warn`
26+
- viewer does not crash on invalid data
27+
28+
## Sample Logs
29+
Example entries displayed by viewer:
30+
31+
```json
32+
[
33+
{
34+
"tool": "asset-browser-v2",
35+
"type": "EMPTY",
36+
"message": "No hostContextId was provided for this tool.",
37+
"details": { "hostContextId": "" },
38+
"timestamp": "2026-05-01T00:00:00.000Z"
39+
},
40+
{
41+
"tool": "tilemap-studio-v2",
42+
"type": "INVALID",
43+
"message": "Expected payloadJson.tileMapDocument.",
44+
"details": { "hostContextId": "tilemap-invalid-1" },
45+
"timestamp": "2026-05-01T00:00:00.000Z"
46+
}
47+
]
48+
```
49+
50+
## Validation Results
51+
Commands run:
52+
1. `node --check tests/runtime/V2ErrorViewer.test.mjs`
53+
Result: **PASS**
54+
2. `node tests/runtime/V2ErrorViewer.test.mjs`
55+
Result: **PASS** (writes `tmp/v2-error-viewer-results.json`)
56+
3. `node --check tools/workspace-v2/index.js`
57+
Result: **PASS**
58+
59+
Runtime test coverage:
60+
- inserted mock logs into localStorage (including invalid rows)
61+
- validated read + filter behavior
62+
- validated structure preservation for valid rows
63+
- validated grouping by tool
64+
- validated clear behavior (`[]`)
65+
66+
## No Fallback Confirmation
67+
- No fallback/default/demo log data introduced.
68+
- Empty state remains explicit when no logs are present.
69+
- Invalid logs are ignored with warning; viewer remains operational.
70+
71+
## Scope Confirmation
72+
- No schemas changed.
73+
- No samples changed.
74+
- No games changed.
75+
- No Workspace Manager v1 changes.
76+
- No platformShell changes.
77+
- No `tools/shared/*` changes.
78+
- V2 tool logging format was not modified.
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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 workspaceJsPath = path.join(repoRoot, "tools", "workspace-v2", "index.js");
11+
const resultsPath = path.join(repoRoot, "tmp", "v2-error-viewer-results.json");
12+
13+
const LOG_STORAGE_KEY = "v2-error-logs";
14+
const VALID_TYPES = new Set(["EMPTY", "INVALID", "RUNTIME"]);
15+
16+
class MemoryLocalStorage {
17+
constructor() {
18+
this.values = new Map();
19+
}
20+
21+
getItem(key) {
22+
if (!this.values.has(String(key))) {
23+
return null;
24+
}
25+
return this.values.get(String(key));
26+
}
27+
28+
setItem(key, value) {
29+
this.values.set(String(key), String(value));
30+
}
31+
}
32+
33+
function readText(filePath) {
34+
return fs.readFileSync(filePath, "utf8");
35+
}
36+
37+
function checkJsSyntax(jsPath) {
38+
try {
39+
execFileSync(process.execPath, ["--check", jsPath], {
40+
cwd: repoRoot,
41+
stdio: ["ignore", "pipe", "pipe"]
42+
});
43+
return { syntaxValid: true, syntaxError: "" };
44+
} catch (error) {
45+
return {
46+
syntaxValid: false,
47+
syntaxError: (error?.stderr || error?.stdout || error?.message || "").toString().trim()
48+
};
49+
}
50+
}
51+
52+
function isValidErrorLogEntry(errorLogEntry) {
53+
if (!errorLogEntry || typeof errorLogEntry !== "object" || Array.isArray(errorLogEntry)) {
54+
return false;
55+
}
56+
if (typeof errorLogEntry.tool !== "string" || !errorLogEntry.tool.trim()) {
57+
return false;
58+
}
59+
if (!VALID_TYPES.has(errorLogEntry.type)) {
60+
return false;
61+
}
62+
if (typeof errorLogEntry.message !== "string" || !errorLogEntry.message.trim()) {
63+
return false;
64+
}
65+
if (!errorLogEntry.details || typeof errorLogEntry.details !== "object" || Array.isArray(errorLogEntry.details)) {
66+
return false;
67+
}
68+
if (typeof errorLogEntry.timestamp !== "string" || !errorLogEntry.timestamp.trim()) {
69+
return false;
70+
}
71+
return true;
72+
}
73+
74+
function readErrorLogsFromStorage(localStorageLike, warnings) {
75+
const rawErrorLogs = localStorageLike.getItem(LOG_STORAGE_KEY);
76+
if (!rawErrorLogs) {
77+
return [];
78+
}
79+
let parsedErrorLogs = null;
80+
try {
81+
parsedErrorLogs = JSON.parse(rawErrorLogs);
82+
} catch (error) {
83+
warnings.push(`Ignoring invalid v2-error-logs JSON: ${error instanceof Error ? error.message : "unknown error"}`);
84+
return [];
85+
}
86+
if (!Array.isArray(parsedErrorLogs)) {
87+
warnings.push("Ignoring invalid v2-error-logs value: expected array.");
88+
return [];
89+
}
90+
const validErrorLogs = [];
91+
let invalidCount = 0;
92+
parsedErrorLogs.forEach((errorLogEntry) => {
93+
if (isValidErrorLogEntry(errorLogEntry)) {
94+
validErrorLogs.push(errorLogEntry);
95+
return;
96+
}
97+
invalidCount += 1;
98+
});
99+
if (invalidCount > 0) {
100+
warnings.push(`Ignored ${invalidCount} invalid error log entr${invalidCount === 1 ? "y" : "ies"}.`);
101+
}
102+
return validErrorLogs;
103+
}
104+
105+
function groupErrorLogsByTool(errorLogs) {
106+
const grouped = {};
107+
errorLogs.forEach((errorLogEntry) => {
108+
if (!Object.prototype.hasOwnProperty.call(grouped, errorLogEntry.tool)) {
109+
grouped[errorLogEntry.tool] = [];
110+
}
111+
grouped[errorLogEntry.tool].push(errorLogEntry);
112+
});
113+
return grouped;
114+
}
115+
116+
export function run() {
117+
const failures = [];
118+
const workspaceJsExists = fs.existsSync(workspaceJsPath);
119+
const workspaceJsText = workspaceJsExists ? readText(workspaceJsPath) : "";
120+
const { syntaxValid, syntaxError } = checkJsSyntax(workspaceJsPath);
121+
122+
const hasErrorStorageKey = workspaceJsText.includes('this.errorLogsStorageKey = "v2-error-logs"');
123+
const hasReadMethod = workspaceJsText.includes("readErrorLogs()");
124+
const hasGroupMethod = workspaceJsText.includes("groupErrorLogsByTool(errorLogs)");
125+
const hasClearMethod = workspaceJsText.includes("clearErrorLogs()");
126+
const hasIgnoreInvalidWarning = workspaceJsText.includes("[WorkspaceV2ErrorViewer] Ignored");
127+
128+
if (!workspaceJsExists) failures.push("Missing tools/workspace-v2/index.js.");
129+
if (!syntaxValid) failures.push("tools/workspace-v2/index.js failed syntax check.");
130+
if (!hasErrorStorageKey) failures.push("Workspace V2 missing v2-error-logs storage key wiring.");
131+
if (!hasReadMethod) failures.push("Workspace V2 missing readErrorLogs() method.");
132+
if (!hasGroupMethod) failures.push("Workspace V2 missing groupErrorLogsByTool(errorLogs) method.");
133+
if (!hasClearMethod) failures.push("Workspace V2 missing clearErrorLogs() method.");
134+
if (!hasIgnoreInvalidWarning) failures.push("Workspace V2 missing invalid entry warning path.");
135+
136+
const localStorageLike = new MemoryLocalStorage();
137+
const now = new Date().toISOString();
138+
const mockLogs = [
139+
{
140+
tool: "asset-browser-v2",
141+
type: "EMPTY",
142+
message: "No hostContextId was provided.",
143+
details: { hostContextId: "" },
144+
timestamp: now
145+
},
146+
{
147+
tool: "tilemap-studio-v2",
148+
type: "INVALID",
149+
message: "Expected payloadJson.tileMapDocument.",
150+
details: { hostContextId: "tilemap-invalid-1" },
151+
timestamp: now
152+
},
153+
{
154+
tool: "tilemap-studio-v2",
155+
type: "RUNTIME",
156+
message: "Unable to read session context: runtime-test-injection",
157+
details: { hostContextId: "tilemap-runtime-1" },
158+
timestamp: now
159+
},
160+
{
161+
tool: "",
162+
type: "INVALID",
163+
message: "Bad row",
164+
details: {},
165+
timestamp: now
166+
},
167+
{
168+
type: "EMPTY",
169+
message: "Missing tool field",
170+
details: {},
171+
timestamp: now
172+
}
173+
];
174+
175+
localStorageLike.setItem(LOG_STORAGE_KEY, JSON.stringify(mockLogs));
176+
const warnings = [];
177+
const validErrorLogs = readErrorLogsFromStorage(localStorageLike, warnings);
178+
const groupedErrorLogs = groupErrorLogsByTool(validErrorLogs);
179+
180+
if (validErrorLogs.length !== 3) failures.push(`Expected 3 valid logs after filtering, got ${validErrorLogs.length}.`);
181+
if (!warnings.some((entry) => entry.includes("Ignored 2 invalid error log entries."))) {
182+
failures.push("Expected warning for ignored invalid entries.");
183+
}
184+
if (!Object.prototype.hasOwnProperty.call(groupedErrorLogs, "asset-browser-v2")) {
185+
failures.push("Grouped logs missing asset-browser-v2 bucket.");
186+
}
187+
if (!Object.prototype.hasOwnProperty.call(groupedErrorLogs, "tilemap-studio-v2")) {
188+
failures.push("Grouped logs missing tilemap-studio-v2 bucket.");
189+
}
190+
if ((groupedErrorLogs["tilemap-studio-v2"] || []).length !== 2) {
191+
failures.push(`Expected 2 tilemap-studio-v2 logs, got ${(groupedErrorLogs["tilemap-studio-v2"] || []).length}.`);
192+
}
193+
194+
localStorageLike.setItem(LOG_STORAGE_KEY, "[]");
195+
const warningsAfterClear = [];
196+
const logsAfterClear = readErrorLogsFromStorage(localStorageLike, warningsAfterClear);
197+
if (logsAfterClear.length !== 0) {
198+
failures.push(`Expected 0 logs after clear, got ${logsAfterClear.length}.`);
199+
}
200+
201+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
202+
fs.writeFileSync(resultsPath, `${JSON.stringify({
203+
generatedAt: new Date().toISOString(),
204+
failures,
205+
workspaceChecks: {
206+
workspaceJsExists,
207+
syntaxValid,
208+
syntaxError,
209+
hasErrorStorageKey,
210+
hasReadMethod,
211+
hasGroupMethod,
212+
hasClearMethod,
213+
hasIgnoreInvalidWarning
214+
},
215+
localStorageScenario: {
216+
insertedCount: mockLogs.length,
217+
validCount: validErrorLogs.length,
218+
warnings,
219+
groupedCounts: Object.keys(groupedErrorLogs).sort().map((toolId) => ({
220+
tool: toolId,
221+
count: groupedErrorLogs[toolId].length
222+
})),
223+
clearedCount: logsAfterClear.length
224+
},
225+
sampleLogs: validErrorLogs
226+
}, null, 2)}\n`, "utf8");
227+
228+
console.log(`v2 error viewer results: ${resultsPath}`);
229+
assert.equal(failures.length, 0, `V2 error viewer failures: ${failures.join(" | ")}`);
230+
return { failures };
231+
}
232+
233+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
234+
try {
235+
const summary = run();
236+
console.log(JSON.stringify(summary, null, 2));
237+
} catch (error) {
238+
console.error(error);
239+
process.exitCode = 1;
240+
}
241+
}

tools/workspace-v2/index.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ <h2>Session Library</h2>
6969
<ul id="workspaceV2SessionList" aria-label="Workspace V2 saved sessions"></ul>
7070
</section>
7171

72+
<section class="hub-panel">
73+
<h2>Error Viewer</h2>
74+
<p>Reads structured V2 tool logs from localStorage key <code>v2-error-logs</code>.</p>
75+
<div>
76+
<button id="workspaceV2RefreshErrorLogsButton" type="button">Refresh Error Logs</button>
77+
<button id="workspaceV2ClearErrorLogsButton" type="button">Clear Error Logs</button>
78+
</div>
79+
<p id="workspaceV2ErrorLogsEmptyState">No error logs found.</p>
80+
<div id="workspaceV2ErrorLogsList" aria-label="Workspace V2 error logs"></div>
81+
</section>
82+
7283
<section class="hub-panel">
7384
<h2>Session Output</h2>
7485
<pre id="workspaceV2Status">No fixture loaded.</pre>

0 commit comments

Comments
 (0)