Skip to content

Commit da1b19b

Browse files
author
DavidQ
committed
Fix status header order and write generated previews directly to manifest target - PR_26127_013-status-header-order-and-preview-write-path-fix
1 parent daeee70 commit da1b19b

5 files changed

Lines changed: 127 additions & 25 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# PR_26127_013-status-header-order-and-preview-write-path-fix
2+
3+
## Summary
4+
- Changed Asset Manager V2 Status header order from `Status Clear +` to `Status + Clear`.
5+
- Replaced Preview Generator V2 workspace-launch download behavior with direct writes to the hydrated preview target URL path.
6+
- Logged the workspace direct-write target before generation and logged the successful direct write path after PUT completion.
7+
- Added explicit FAIL logging when the hydrated workspace write path is invalid, unavailable, or rejected by the server.
8+
- Kept manual repo-folder launches on the existing File System Access write path.
9+
10+
## Validation
11+
- PASS: `npm run test:workspace-v2`
12+
- PASS: Workspace Manager V2 launch into Asset Manager V2 shows Status header order `Status + Clear`.
13+
- PASS: Workspace Manager V2 launch into Preview Generator V2 with Pong keeps Generate Image enabled.
14+
- PASS: Preview Generator V2 writes to `games/Pong/assets/images/preview.svg` through the hydrated workspace target path.
15+
- PASS: Preview Generator V2 does not open a download/save flow for the valid hydrated workspace preview target.
16+
- PASS: Direct write target is logged to Status.
17+
- PASS: No deprecated `tools/workspace-v2` changes.
18+
- PASS: No sample JSON changes.
19+
- PASS: No game asset files were modified by validation.
20+
- SKIPPED: Full samples smoke test, per Preview Generator write-path scope.
21+
22+
## Manual Validation Notes
23+
- Open Workspace Manager V2.
24+
- Select Pong.
25+
- Launch Preview Generator V2 from the tool tile.
26+
- Confirm Generate Image is enabled.
27+
- Click Generate Image.
28+
- Confirm Status logs `Workspace launch direct preview write target: games/Pong/assets/images/preview.svg.`
29+
- Confirm Status logs `Direct preview write target: games/Pong/assets/images/preview.svg`.
30+
- Confirm no browser save/download prompt appears for the hydrated workspace target.
31+
- Open Asset Manager V2 through Workspace Manager V2 and confirm the Status header reads `Status + Clear`.

tests/helpers/playwrightRepoServer.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function contentTypeForPath(filePath) {
1818
}
1919

2020
export async function startRepoServer() {
21+
const previewWrites = new Map();
2122
const server = http.createServer(async (request, response) => {
2223
try {
2324
const requestUrl = new URL(request.url || "/", "http://127.0.0.1");
@@ -29,6 +30,20 @@ export async function startRepoServer() {
2930
response.end("Forbidden");
3031
return;
3132
}
33+
if (request.method === "PUT") {
34+
const bodyChunks = [];
35+
for await (const chunk of request) {
36+
bodyChunks.push(chunk);
37+
}
38+
const repoRelativePath = normalizedPath
39+
.replaceAll("\\", "/")
40+
.replace(/^\/+/, "");
41+
previewWrites.set(repoRelativePath, Buffer.concat(bodyChunks).toString("utf8"));
42+
response.statusCode = 200;
43+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
44+
response.end("OK");
45+
return;
46+
}
3247
let targetPath = absolutePath;
3348
const stat = await fs.stat(targetPath).catch(() => null);
3449
if (stat && stat.isDirectory()) {
@@ -56,6 +71,7 @@ export async function startRepoServer() {
5671

5772
return {
5873
baseUrl: `http://127.0.0.1:${address.port}`,
74+
previewWrites,
5975
close: async () => {
6076
await new Promise((resolve, reject) => {
6177
const forceClose = setTimeout(() => {

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,8 @@ test.describe("Workspace Manager V2 bootstrap", () => {
359359
await expect(page.locator(".asset-manager-v2__workspace__menu button")).toHaveText(["Return to Workspace"]);
360360
await expect(page.locator("#statusLog")).toHaveValue(/Workspace Manager V2 loaded 14 validated assets from tools\.asset-manager-v2\.assets/);
361361
await expect(page.locator("#statusLog")).toHaveValue(/Workspace Manager V2 loaded \d+ palette colors from active palette context/);
362+
const assetStatusHeaderOrder = await page.locator(".asset-manager-v2__status-accordion-header").evaluate((header) => Array.from(header.querySelectorAll(":scope > span, :scope > div > span, :scope > div > button"), (element) => element.textContent.trim()));
363+
expect(assetStatusHeaderOrder).toEqual(["Status", "+", "Clear"]);
362364

363365
const workspacePreviewContext = await page.evaluate(async () => {
364366
const { WorkspaceBridge } = await import("/tools/asset-manager-v2/js/services/WorkspaceBridge.js");
@@ -544,6 +546,18 @@ test.describe("Workspace Manager V2 bootstrap", () => {
544546
await expect(page.locator("#log")).toContainText("WARN Workspace background image role is missing; using manifest palette background color Background #05070A.");
545547
await expect(page.locator("#log")).not.toContainText("FAIL Workspace background hydration");
546548
await expect(page.locator("#log")).toContainText("OK Workspace manifest preview source is valid at games/Pong/assets/images/preview.svg.");
549+
await page.locator("#baseUrl").fill(server.baseUrl);
550+
await expect(page.locator("#executeBtn")).toBeEnabled();
551+
let previewDownloadOpened = false;
552+
page.on("download", () => {
553+
previewDownloadOpened = true;
554+
});
555+
await page.locator("#executeBtn").click();
556+
await expect(page.locator("#log")).toContainText("Workspace launch direct preview write target: games/Pong/assets/images/preview.svg.", { timeout: 20000 });
557+
await expect(page.locator("#log")).toContainText("Direct preview write target: games/Pong/assets/images/preview.svg", { timeout: 20000 });
558+
await expect(page.locator("#log")).toContainText("OK Pong", { timeout: 20000 });
559+
expect(previewDownloadOpened).toBe(false);
560+
expect(server.previewWrites.get("games/Pong/assets/images/preview.svg")).toContain("<svg");
547561
await page.locator("#returnToWorkspaceButton").click();
548562
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
549563
expect(pageErrors).toEqual([]);

tools/asset-manager-v2/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,8 @@ <h2 class="tools-platform-frame__eyebrow">Asset-only First-Class Tool V2</h2>
192192
<div class="accordion-v2__header asset-manager-v2__status-accordion-header" role="button" tabindex="0" aria-expanded="true" aria-controls="statusLogContent">
193193
<span>Status</span>
194194
<div class="asset-manager-v2__status-header-actions">
195-
<button id="clearStatusButton" class="asset-manager-v2__status-clear-button" type="button">Clear</button>
196195
<span class="accordion-v2__icon" aria-hidden="true">+</span>
196+
<button id="clearStatusButton" class="asset-manager-v2__status-clear-button" type="button">Clear</button>
197197
</div>
198198
</div>
199199
<div id="statusLogContent" class="accordion-v2__content">

tools/preview-generator-v2/PreviewGeneratorV2App.js

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -585,32 +585,55 @@ async function shouldRewrite(targetDirHandle) {
585585
return { rewrite: false, reason: "existing-preview-without-capture-timeout" };
586586
}
587587

588-
function downloadWorkspacePreview(svgContent, outputPath) {
589-
const BlobCtor = window.Blob;
590-
const urlApi = window.URL || window.webkitURL;
591-
if (typeof BlobCtor !== "function" || !urlApi?.createObjectURL) {
592-
throw new Error("Browser download APIs are unavailable for workspace launch output.");
593-
}
594-
const blob = new BlobCtor([svgContent], { type: "image/svg+xml" });
595-
const url = urlApi.createObjectURL(blob);
596-
const link = window.document.createElement("a");
597-
link.href = url;
598-
link.download = OUTPUT_NAME;
599-
link.rel = "noopener";
600-
link.dataset.workspaceOutputPath = outputPath;
601-
window.document.body.append(link);
602-
link.click();
603-
link.remove();
604-
window.setTimeout(() => {
605-
urlApi.revokeObjectURL(url);
606-
}, 0);
588+
function previewWriteError(message) {
589+
const error = new Error(message);
590+
error.previewWriteFailed = true;
591+
return error;
592+
}
593+
594+
function isPreviewWriteError(error) {
595+
return Boolean(error?.previewWriteFailed);
596+
}
597+
598+
function validateWorkspacePreviewWritePath(entry) {
599+
if (!isWorkspaceManagerLaunch() || !workspaceRepoRootHydrated || !workspacePreviewFileValid) {
600+
throw previewWriteError("Workspace preview write path is unavailable because launch hydration is incomplete.");
601+
}
602+
const targetPath = normalizeWorkspacePath(getWorkspacePreviewTargetDisplayPath());
603+
if (!targetPath) {
604+
throw previewWriteError("Workspace preview write path is empty.");
605+
}
606+
const targetParts = targetPath.split("/");
607+
if (targetParts.includes("..") || targetPath.startsWith(".")) {
608+
throw previewWriteError(`Workspace preview write path is invalid: ${targetPath}.`);
609+
}
610+
const expectedPrefix = `games/${entry.name}/`;
611+
if (entry.targetType !== "games" || entry.name !== workspacePreviewGameId || !targetPath.startsWith(expectedPrefix)) {
612+
throw previewWriteError(`Workspace preview write path ${targetPath} does not match the hydrated ${workspacePreviewGameId} game context.`);
613+
}
614+
return targetPath;
615+
}
616+
617+
async function writeWorkspacePreview(svgContent, entry) {
618+
const targetPath = validateWorkspacePreviewWritePath(entry);
619+
const targetUrl = new URL(`/${targetPath}`, window.location.href);
620+
const response = await fetch(targetUrl.href, {
621+
method: "PUT",
622+
headers: {
623+
"Content-Type": "image/svg+xml; charset=utf-8"
624+
},
625+
body: svgContent
626+
});
627+
if (!response.ok) {
628+
const details = await response.text().catch(() => "");
629+
throw previewWriteError(`${targetPath} returned ${response.status}${details ? `: ${details}` : ""}.`);
630+
}
631+
logger.log(`Direct preview write target: ${targetPath}`);
607632
}
608633

609634
async function writePreview(targetDirHandle, svgContent, entry) {
610635
if (!targetDirHandle && isWorkspaceManagerLaunch() && workspaceRepoRootHydrated) {
611-
const outputPath = getFullOutputPath(entry);
612-
downloadWorkspacePreview(svgContent, outputPath);
613-
logger.log(`DL ${outputPath}`);
636+
await writeWorkspacePreview(svgContent, entry);
614637
return;
615638
}
616639

@@ -678,8 +701,26 @@ async function processOne(entry, baseUrl, waitMs) {
678701
logger.log("");
679702
return { id: label, status: "written", reason: decision.reason };
680703
} catch (error) {
704+
if (isPreviewWriteError(error)) {
705+
logger.log(`FAIL Direct preview write failed: ${error.message}`);
706+
logger.log("");
707+
return { id: label, status: "failed", reason: error.message };
708+
}
681709
const fallback = capture.buildFallbackSvg(`${CAPTURE_TIMEOUT_MARKER}: ${error.message}`);
682-
await writePreview(targetDirHandle, fallback, entry);
710+
try {
711+
await writePreview(targetDirHandle, fallback, entry);
712+
} catch (writeError) {
713+
const reason = isPreviewWriteError(writeError)
714+
? writeError.message
715+
: `${error.message}; fallback write failed: ${writeError.message}`;
716+
if (isPreviewWriteError(writeError)) {
717+
logger.log(`FAIL Direct preview write failed: ${writeError.message}`);
718+
} else {
719+
logger.log(`FAIL ${label} (${reason})`);
720+
}
721+
logger.log("");
722+
return { id: label, status: "failed", reason };
723+
}
683724
ui.setLastGeneratedImage(fallback, label);
684725
logger.log(`FAIL ${label} (${error.message})`);
685726
logger.log("");
@@ -840,7 +881,7 @@ class PreviewGeneratorV2App {
840881
logger.log(`Capture mode: ${ui.getCaptureModeLabel()}`);
841882
logger.log(`Force rewrite: ${ui.renderControls.isForceRewrite()}`);
842883
if (!repoDirHandle && isWorkspaceManagerLaunch()) {
843-
logger.log("Workspace launch repo root was hydrated from context; output will download because browsers cannot write to a repo path without a directory handle.");
884+
logger.log(`Workspace launch direct preview write target: ${getWorkspacePreviewTargetDisplayPath() || "unavailable"}.`);
844885
}
845886
logger.log("");
846887

0 commit comments

Comments
 (0)