Skip to content

Commit 3ab6796

Browse files
author
DavidQ
committed
Hydrate real repo root path for direct preview generation writes - PR_26127_014-preview-generator-real-repo-root-hydration
1 parent da1b19b commit 3ab6796

8 files changed

Lines changed: 323 additions & 36 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# PR_26127_014-preview-generator-real-repo-root-hydration
2+
3+
## Summary
4+
- Updated Preview Generator V2 status header order from `Status Clear +` to `Status + Clear`.
5+
- Hydrated Preview Generator V2 workspace launches with a real absolute filesystem `repoRoot` resolved by Workspace Manager V2.
6+
- Direct workspace preview generation now combines `repoRoot` with the manifest-relative generated preview target, logs the resolved paths, and writes to the validated absolute output path.
7+
- Display-only/non-absolute `repoRoot` values now fail workspace launch hydration, keep Generate Preview disabled, and do not silently fall back.
8+
9+
## Repo Root Hydration Notes
10+
- Workspace Manager V2 resolves an absolute repo root for Preview Generator V2 launch context only.
11+
- Existing workspace manifests remain manifest-relative for `gameRoot`, `assetsPath`, and asset paths.
12+
- The Playwright repo server exposes the current absolute repo root for launch hydration and validates preview writes stay inside that repo root.
13+
14+
## Direct Write Notes
15+
- Hydrated Asteroids launch resolves the generated preview target as `games/Asteroids/assets/images/preview.svg`.
16+
- Preview Generator V2 logs:
17+
- resolved `repoRoot`
18+
- resolved absolute preview output path
19+
- direct write target
20+
- direct write success or failure
21+
- If the launch context has `repoRoot: "HTML-JavaScript-Gaming"` or any other non-absolute display value, direct write remains disabled.
22+
23+
## UI Notes
24+
- Preview Generator V2 status header now renders as `Status + Clear`.
25+
- The pre-generation preview image can still display the manifest-selected preview source such as `preview.png`; direct generation writes to the generated `preview.svg` target.
26+
27+
## Validation
28+
- `npm run test:workspace-v2`
29+
- Result: PASS, 10 tests passed.
30+
- Validated Asteroids Preview Generator V2 workspace launch, absolute repo root hydration, direct write to `games/Asteroids/assets/images/preview.svg`, status path logging, and blocked non-absolute repoRoot handling.
31+
32+
## Out Of Scope
33+
- Deprecated `tools/workspace-v2` was not modified.
34+
- Sample JSON was not modified.
35+
- Full samples smoke test was skipped because this PR is Preview Generator repo hydration scoped.

tests/helpers/playwrightRepoServer.mjs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,56 @@ function contentTypeForPath(filePath) {
1717
return "application/octet-stream";
1818
}
1919

20+
function isInsideRepoRoot(absolutePath) {
21+
const relativePath = path.relative(repoRoot, absolutePath);
22+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
23+
}
24+
2025
export async function startRepoServer() {
2126
const previewWrites = new Map();
27+
const previewAbsoluteWrites = new Map();
2228
const server = http.createServer(async (request, response) => {
2329
try {
2430
const requestUrl = new URL(request.url || "/", "http://127.0.0.1");
2531
const decodedPath = decodeURIComponent(requestUrl.pathname);
32+
if (request.method === "GET" && decodedPath === "/__workspace-manager-v2/repo-root") {
33+
response.statusCode = 200;
34+
response.setHeader("Content-Type", "application/json; charset=utf-8");
35+
response.end(JSON.stringify({ repoRoot }));
36+
return;
37+
}
38+
if (request.method === "PUT" && decodedPath === "/__workspace-manager-v2/write-preview") {
39+
const absoluteWritePath = String(request.headers["x-workspace-preview-absolute-path"] || "");
40+
const relativeWritePath = String(request.headers["x-workspace-preview-relative-path"] || "");
41+
const resolvedWritePath = path.resolve(absoluteWritePath);
42+
const repoRelativePath = relativeWritePath
43+
? relativeWritePath.replaceAll("\\", "/").replace(/^\/+/, "")
44+
: path.relative(repoRoot, resolvedWritePath).replaceAll("\\", "/");
45+
if (!absoluteWritePath || !isInsideRepoRoot(resolvedWritePath)) {
46+
response.statusCode = 403;
47+
response.end("Preview write path must be inside the repo root.");
48+
return;
49+
}
50+
if (!repoRelativePath || repoRelativePath.startsWith("..") || path.isAbsolute(repoRelativePath)) {
51+
response.statusCode = 400;
52+
response.end("Preview write relative path is invalid.");
53+
return;
54+
}
55+
const bodyChunks = [];
56+
for await (const chunk of request) {
57+
bodyChunks.push(chunk);
58+
}
59+
const body = Buffer.concat(bodyChunks).toString("utf8");
60+
previewWrites.set(repoRelativePath, body);
61+
previewAbsoluteWrites.set(resolvedWritePath, body);
62+
response.statusCode = 200;
63+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
64+
response.end("OK");
65+
return;
66+
}
2667
const normalizedPath = path.normalize(decodedPath).replace(/^(\.\.[/\\])+/, "");
2768
const absolutePath = path.resolve(repoRoot, `.${normalizedPath}`);
28-
if (!absolutePath.startsWith(repoRoot)) {
69+
if (!isInsideRepoRoot(absolutePath)) {
2970
response.statusCode = 403;
3071
response.end("Forbidden");
3172
return;
@@ -71,6 +112,8 @@ export async function startRepoServer() {
71112

72113
return {
73114
baseUrl: `http://127.0.0.1:${address.port}`,
115+
repoRoot,
116+
previewAbsoluteWrites,
74117
previewWrites,
75118
close: async () => {
76119
await new Promise((resolve, reject) => {

tests/playwright/tools/PreviewGeneratorV2Baseline.spec.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,8 @@ test.describe("Preview Generator V2 baseline", () => {
383383

384384
const statusHeader = page.locator('#statusAccordion .accordion-v2__header[aria-controls="statusAccordionContent"]');
385385
const statusContent = page.locator("#statusAccordionContent");
386+
const statusHeaderOrder = await statusHeader.evaluate((header) => Array.from(header.querySelectorAll(":scope > span, :scope > div > span, :scope > div > button"), (element) => element.textContent.trim()));
387+
expect(statusHeaderOrder).toEqual(["Status", "+", "Clear"]);
386388
await expect(statusHeader.locator("#clearLogBtn")).toBeVisible();
387389
await expect(statusHeader).toHaveAttribute("aria-expanded", "true");
388390
await expect(statusContent).toBeVisible();

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect, test } from "@playwright/test";
22
import { readFile } from "node:fs/promises";
3+
import path from "node:path";
34
import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs";
45
import { workspaceV2CoverageReporter as coverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs";
56

@@ -513,8 +514,6 @@ test.describe("Workspace Manager V2 bootstrap", () => {
513514
await expect(page.locator("#paletteStatus")).toHaveText("Loaded active workspace palette Asteroids Palette.");
514515
await page.locator("#returnToWorkspaceButton").click();
515516
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
516-
await page.locator("#activeGameSelect").selectOption("Pong");
517-
await expect(page.locator("#activeGameSummary")).toContainText("games/Pong/");
518517
await expect(previewTile).toBeEnabled();
519518
await expect(previewTile).toContainText("Schema-valid manifest");
520519
await page.locator('[data-workspace-tool-id="preview-generator-v2"]').click();
@@ -524,40 +523,50 @@ test.describe("Workspace Manager V2 bootstrap", () => {
524523
await expect(page.locator('[data-launch-mode-nav="workspace"] button')).toHaveText(["Generate Image", "Return to Workspace"]);
525524
await expect(page.locator("#executeBtn")).toBeVisible();
526525
await expect(page.locator("#executeBtn")).toBeEnabled();
527-
await expect(page.locator("#repoSelectedValue")).toHaveText("HTML-JavaScript-Gaming");
526+
await expect(page.locator("#repoSelectedValue")).toHaveText(server.repoRoot);
528527
await expect(page.locator("#workspaceContextValue")).toHaveCount(0);
529528
await expect(page.locator("#repoDestinationContent")).not.toContainText("Workspace launch");
530529
await expect(page.locator("#targetTypeGames")).toBeChecked();
531530
await expect(page.locator('label[for="targetTypeGames"]')).toBeVisible();
532531
await expect(page.locator('label[for="targetTypeSamples"]')).toBeHidden();
533532
await expect(page.locator('label[for="targetTypeTools"]')).toBeHidden();
534533
await expect(page.locator("#assetFolder")).toHaveValue("assets/images");
535-
await expect(page.locator("#sampleList")).toHaveValue("Pong");
536-
await expect(page.locator("#previewTargetValue")).toHaveText("games/Pong/assets/images/preview.svg");
534+
await expect(page.locator("#sampleList")).toHaveValue("Asteroids");
535+
await expect(page.locator("#previewTargetValue")).toHaveText("games/Asteroids/assets/images/preview.svg");
537536
await expect(page.locator("#lastGeneratedImagePreview")).toBeVisible();
538-
await expect(page.locator("#lastGeneratedImageMeta")).toHaveText("Preview target: games/Pong/assets/images/preview.svg");
539-
await expect(page.locator("#log")).toContainText("OK Workspace launch context hydrated for Pong.");
540-
await expect(page.locator("#log")).toContainText("Repo selected from Workspace Manager V2 manifest repoRoot: HTML-JavaScript-Gaming.");
537+
await expect(page.locator("#lastGeneratedImageMeta")).toHaveText("Preview target: games/Asteroids/assets/images/preview.png");
538+
const absoluteAsteroidsPreviewPath = path.join(server.repoRoot, "games", "Asteroids", "assets", "images", "preview.svg");
539+
await expect(page.locator("#log")).toContainText("OK Workspace launch context hydrated for Asteroids.");
540+
await expect(page.locator("#log")).toContainText(`Repo selected from Workspace Manager V2 manifest repoRoot: ${server.repoRoot}.`);
541+
await expect(page.locator("#log")).toContainText(`Resolved repoRoot: ${server.repoRoot}`);
542+
await expect(page.locator("#log")).toContainText(`Resolved absolute preview output path: ${absoluteAsteroidsPreviewPath}`);
541543
await expect(page.locator("#log")).toContainText("Asset folder: assets\\images");
542-
await expect(page.locator("#log")).toContainText("Manifest preview asset: assets.image.preview.preview (image/svg)");
543-
await expect(page.locator("#log")).toContainText("Manifest preview source: games/Pong/assets/images/preview.svg");
544-
await expect(page.locator("#log")).toContainText("Generated preview target: games/Pong/assets/images/preview.svg");
545-
await expect(page.locator("#log")).toContainText("Preview target: games/Pong/assets/images/preview.svg");
546-
await expect(page.locator("#log")).toContainText("WARN Workspace background image role is missing; using manifest palette background color Background #05070A.");
544+
await expect(page.locator("#log")).toContainText("Manifest preview asset: assets.image.preview.preview (image/png)");
545+
await expect(page.locator("#log")).toContainText("Manifest preview source: games/Asteroids/assets/images/preview.png");
546+
await expect(page.locator("#log")).toContainText("Generated preview target: games/Asteroids/assets/images/preview.svg");
547+
await expect(page.locator("#log")).toContainText("Preview target: games/Asteroids/assets/images/preview.svg");
548+
await expect(page.locator("#log")).toContainText("Workspace background source: assets.image.background.deluxe -> games/Asteroids/assets/images/deluxe.png");
549+
await expect(page.locator("#log")).toContainText("Workspace background color: Space Black #020617 from palette-manager-v2 swatch.");
547550
await expect(page.locator("#log")).not.toContainText("FAIL Workspace background hydration");
548-
await expect(page.locator("#log")).toContainText("OK Workspace manifest preview source is valid at games/Pong/assets/images/preview.svg.");
551+
await expect(page.locator("#log")).toContainText("OK Workspace manifest preview source is valid at games/Asteroids/assets/images/preview.png.");
552+
const previewStatusHeaderOrder = await page.locator(".preview-generator-v2__status-accordion-header").evaluate((header) => Array.from(header.querySelectorAll(":scope > span, :scope > div > span, :scope > div > button"), (element) => element.textContent.trim()));
553+
expect(previewStatusHeaderOrder).toEqual(["Status", "+", "Clear"]);
549554
await page.locator("#baseUrl").fill(server.baseUrl);
550555
await expect(page.locator("#executeBtn")).toBeEnabled();
551556
let previewDownloadOpened = false;
552557
page.on("download", () => {
553558
previewDownloadOpened = true;
554559
});
555560
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 });
561+
await expect(page.locator("#log")).toContainText("Workspace launch direct preview write target: games/Asteroids/assets/images/preview.svg.", { timeout: 20000 });
562+
await expect(page.locator("#log")).toContainText(`Workspace launch absolute preview output path: ${absoluteAsteroidsPreviewPath}.`, { timeout: 20000 });
563+
await expect(page.locator("#log")).toContainText("Direct preview write target: games/Asteroids/assets/images/preview.svg", { timeout: 20000 });
564+
await expect(page.locator("#log")).toContainText(`Direct preview absolute path: ${absoluteAsteroidsPreviewPath}`, { timeout: 20000 });
565+
await expect(page.locator("#log")).toContainText(`OK Direct preview write completed: ${absoluteAsteroidsPreviewPath}`, { timeout: 20000 });
566+
await expect(page.locator("#log")).toContainText("OK Asteroids", { timeout: 20000 });
559567
expect(previewDownloadOpened).toBe(false);
560-
expect(server.previewWrites.get("games/Pong/assets/images/preview.svg")).toContain("<svg");
568+
expect(server.previewWrites.get("games/Asteroids/assets/images/preview.svg")).toContain("<svg");
569+
expect(server.previewAbsoluteWrites.get(absoluteAsteroidsPreviewPath)).toContain("<svg");
561570
await page.locator("#returnToWorkspaceButton").click();
562571
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
563572
expect(pageErrors).toEqual([]);
@@ -604,6 +613,36 @@ test.describe("Workspace Manager V2 bootstrap", () => {
604613
}
605614
});
606615

616+
test("blocks Preview Generator V2 workspace direct write when repoRoot is not absolute", async ({ page }) => {
617+
const server = await startRepoServer();
618+
const pageErrors = [];
619+
const hostContextId = "workspace-manager-v2-display-root-context";
620+
const displayRootManifest = JSON.parse(await readFile("games/Asteroids/game.manifest.json", "utf8"));
621+
displayRootManifest.repoRoot = "HTML-JavaScript-Gaming";
622+
623+
page.on("pageerror", (error) => {
624+
pageErrors.push(error.message);
625+
});
626+
627+
await page.addInitScript(({ contextId, manifest }) => {
628+
window.sessionStorage.setItem(contextId, JSON.stringify(manifest));
629+
}, { contextId: hostContextId, manifest: displayRootManifest });
630+
await coverageReporter.start(page);
631+
await page.goto(`${server.baseUrl}/tools/preview-generator-v2/index.html?launch=workspace&fromTool=workspace-manager-v2&hostContextId=${hostContextId}`, { waitUntil: "networkidle" });
632+
633+
try {
634+
await expect(page.locator("#repoSelectedValue")).toHaveText("HTML-JavaScript-Gaming");
635+
await expect(page.locator("#executeBtn")).toBeDisabled();
636+
await expect(page.locator("#log")).toContainText("FAIL Workspace launch context hydration: repoRoot must be an absolute filesystem path, received HTML-JavaScript-Gaming.");
637+
await expect(page.locator("#log")).not.toContainText("OK Workspace launch context hydrated");
638+
expect(server.previewWrites.size).toBe(0);
639+
expect(pageErrors).toEqual([]);
640+
} finally {
641+
await coverageReporter.stop(page);
642+
await server.close();
643+
}
644+
});
645+
607646
test("loads Gravity Well and Pong manifests as current Workspace Manager V2 manifests", async ({ page }) => {
608647
const server = await openWorkspaceManagerV2(page);
609648
const pageErrors = [];

0 commit comments

Comments
 (0)