Skip to content

Commit bbf041b

Browse files
author
DavidQ
committed
Fix Preview Generator workspace launch repo root and preview file hydration - PR_26127_005-workspace-manager-v2-tool-tile-cleanup-preview-fix
1 parent fd7fb2c commit bbf041b

8 files changed

Lines changed: 226 additions & 50 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# PR_26127_006-preview-generator-v2-workspace-launch-hydration-fix
2+
3+
## Scope
4+
5+
- Preview Generator V2 workspace-launch hydration from Workspace Manager V2.
6+
- Workspace Manager V2 Playwright coverage for the Preview Generator V2 launch path.
7+
- Deprecated `tools/workspace-v2` was not modified.
8+
- Sample JSON was not modified.
9+
- No fallback behavior was added.
10+
11+
## Hydration Notes
12+
13+
- Workspace Manager V2 launch no longer writes the workspace/game label into the Preview Generator V2 repo root field.
14+
- The repo root field remains `not selected` until the user selects an actual repo root folder.
15+
- The workspace/game label is shown separately as Workspace launch context.
16+
- Generate Image remains disabled during workspace launch until a valid repo root, target source, asset folder, and preview target file are all valid.
17+
- Repo root selection now validates that the chosen folder exposes repo-level `games` and `tools` directories.
18+
19+
## Preview Target Notes
20+
21+
- Preview Generator V2 now resolves the workspace preview target as the schema-backed bezel image asset from the manifest.
22+
- For Asteroids, the resolved preview target is `games/Asteroids/assets/images/bezel.png`.
23+
- The asset folder still hydrates as `assets/images`, but hydration also records and displays the full preview target file path.
24+
- Preview target validation checks that the resolved file exists and is image-like by response type or image filename extension.
25+
26+
## Validation
27+
28+
- `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs --project=playwright --workers=1 --reporter=list` passed.
29+
- `npm run test:workspace-v2` passed.
30+
- Result: 24 passed.
31+
- Playwright impacted: Yes.
32+
- Full samples smoke test skipped because this PR is Workspace Manager V2 to Preview Generator V2 launch scoped.
33+
34+
## Manual Validation Notes
35+
36+
1. Open Workspace Manager V2.
37+
2. Load Asteroids.
38+
3. Launch Preview Generator V2 from the Preview Generator V2 tile.
39+
4. Confirm Repo selected remains `not selected`, while Workspace launch shows `Asteroids workspace (games/Asteroids)`.
40+
5. Confirm Preview target shows `games/Asteroids/assets/images/bezel.png`.
41+
6. Confirm Generate Image is disabled before selecting the repo root.
42+
7. Click Pick Repo Folder and choose the actual `HTML-JavaScript-Gaming` repo root.
43+
8. Confirm Repo selected shows the actual repo folder and Generate Image becomes enabled.

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,37 @@ async function openToolsIndex(page) {
2424
return server;
2525
}
2626

27+
async function installPreviewGeneratorRepoRootPicker(page) {
28+
await page.addInitScript(() => {
29+
class FakeDirectoryHandle {
30+
constructor(name) {
31+
this.kind = "directory";
32+
this.name = name;
33+
this.children = new Map();
34+
}
35+
36+
addDirectory(name) {
37+
const directory = new FakeDirectoryHandle(name);
38+
this.children.set(name, directory);
39+
return directory;
40+
}
41+
42+
async getDirectoryHandle(name) {
43+
const child = this.children.get(name);
44+
if (child?.kind === "directory") {
45+
return child;
46+
}
47+
throw new Error(`Missing directory: ${name}`);
48+
}
49+
}
50+
51+
const repo = new FakeDirectoryHandle("HTML-JavaScript-Gaming");
52+
repo.addDirectory("games");
53+
repo.addDirectory("tools");
54+
window.showDirectoryPicker = async () => repo;
55+
});
56+
}
57+
2758
test.describe("Workspace Manager V2 bootstrap", () => {
2859
test.afterAll(async () => {
2960
await coverageReporter.writeReport();
@@ -405,29 +436,30 @@ test.describe("Workspace Manager V2 bootstrap", () => {
405436
await expect(page.locator("#paletteStatus")).toHaveText("Loaded active workspace palette Asteroids Palette.");
406437
await page.locator("#returnToWorkspaceButton").click();
407438
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
408-
await page.route("**/games/Asteroids/assets/images/preview.svg", async (route) => {
409-
await route.fulfill({
410-
body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><rect width="8" height="8"/></svg>',
411-
contentType: "image/svg+xml",
412-
status: 200
413-
});
414-
});
439+
await installPreviewGeneratorRepoRootPicker(page);
415440
await page.locator('[data-workspace-tool-id="preview-generator-v2"]').click();
416441
await expect(page).toHaveURL(/preview-generator-v2\/index\.html.*launch=workspace/);
417442
await expect(page.locator('[data-launch-mode-nav="tool"]')).toBeHidden();
418443
await expect(page.locator('[data-launch-mode-nav="workspace"]')).toBeVisible();
419444
await expect(page.locator('[data-launch-mode-nav="workspace"] button')).toHaveText(["Generate Image", "Return to Workspace"]);
420445
await expect(page.locator("#executeBtn")).toBeVisible();
421-
await expect(page.locator("#executeBtn")).toBeEnabled();
422-
await expect(page.locator("#repoSelectedValue")).toHaveText("Asteroids workspace (games/Asteroids)");
446+
await expect(page.locator("#executeBtn")).toBeDisabled();
447+
await expect(page.locator("#repoSelectedValue")).toHaveText("not selected");
448+
await expect(page.locator("#workspaceContextValue")).toHaveText("Asteroids workspace (games/Asteroids)");
423449
await expect(page.locator("#targetTypeGames")).toBeChecked();
424450
await expect(page.locator("#assetFolder")).toHaveValue("assets/images");
425451
await expect(page.locator("#sampleList")).toHaveValue("Asteroids");
452+
await expect(page.locator("#previewTargetValue")).toHaveText("games/Asteroids/assets/images/bezel.png");
426453
await expect(page.locator("#lastGeneratedImagePreview")).toBeVisible();
427-
await expect(page.locator("#lastGeneratedImageMeta")).toHaveText("Last generated: Asteroids preview.svg");
454+
await expect(page.locator("#lastGeneratedImageMeta")).toHaveText("Preview target: games/Asteroids/assets/images/bezel.png");
428455
await expect(page.locator("#log")).toContainText("OK Workspace launch context hydrated for Asteroids.");
456+
await expect(page.locator("#log")).toContainText("Repo selected: not selected; pick the actual repo root folder.");
429457
await expect(page.locator("#log")).toContainText("Asset folder: assets\\images");
430-
await expect(page.locator("#log")).toContainText("OK Loaded existing preview image from /games/Asteroids/assets/images/preview.svg.");
458+
await expect(page.locator("#log")).toContainText("Preview target: games/Asteroids/assets/images/bezel.png");
459+
await expect(page.locator("#log")).toContainText("OK Workspace preview target is valid at games/Asteroids/assets/images/bezel.png.");
460+
await page.locator("#pickRepoBtn").click();
461+
await expect(page.locator("#repoSelectedValue")).toHaveText("HTML-JavaScript-Gaming");
462+
await expect(page.locator("#executeBtn")).toBeEnabled();
431463
await page.locator("#returnToWorkspaceButton").click();
432464
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
433465
expect(pageErrors).toEqual([]);

tools/preview-generator-v2/PreviewGeneratorV2App.js

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ let repoDirHandle = null;
1313
let stopRequested = false;
1414
let repoDisplayName = "";
1515
let isGenerating = false;
16-
let workspaceLaunchReady = false;
16+
let workspacePreviewAssetFolder = "";
17+
let workspacePreviewFileValid = false;
18+
let workspacePreviewGameId = "";
19+
let workspacePreviewTargetPath = "";
1720
const ui = new PreviewGeneratorV2Ui();
1821
const logger = new PreviewGeneratorV2Logger({
1922
statusEl: ui.statusLog.getStatusElement(),
@@ -99,39 +102,62 @@ function workspaceAssetFolder(manifest) {
99102
return assetsPath.slice(gameRoot.length + 1);
100103
}
101104

102-
function workspaceImageAssetFolder(manifest) {
105+
function workspacePreviewTarget(manifest) {
103106
const gameRoot = normalizeWorkspacePath(manifest.gameRoot);
104107
const assetFolder = workspaceAssetFolder(manifest);
105108
if (!assetFolder) {
106-
return "";
109+
return { ok: false, message: "assetsPath must be inside gameRoot." };
107110
}
108111
const imageAsset = Object.values(manifest.tools?.["asset-manager-v2"]?.assets || {})
109-
.find((asset) => asset?.type === "image" && normalizeWorkspacePath(asset.path));
112+
.find((asset) => asset?.type === "image" && asset?.role === "bezel" && normalizeWorkspacePath(asset.path));
113+
if (!imageAsset) {
114+
return { ok: false, message: "manifest must include a bezel image asset path under assetsPath." };
115+
}
110116
const imagePath = normalizeWorkspacePath(imageAsset?.path);
111117
const imagePathFromGameRoot = imagePath.startsWith(`${gameRoot}/`)
112118
? imagePath.slice(gameRoot.length + 1)
113119
: imagePath;
114120
if (!imagePathFromGameRoot.startsWith(`${assetFolder}/`)) {
115-
return "";
121+
return { ok: false, message: `${imagePath || "(empty)"} must resolve under ${assetFolder}.` };
116122
}
117-
const imageFolder = imagePathFromGameRoot.split("/").slice(0, -1).join("/");
118-
return imageFolder || "";
123+
const previewAssetFolder = imagePathFromGameRoot.split("/").slice(0, -1).join("/");
124+
if (!previewAssetFolder) {
125+
return { ok: false, message: `${imagePath} does not include an asset folder.` };
126+
}
127+
return {
128+
ok: true,
129+
previewAssetFolder,
130+
previewTargetPath: `${gameRoot}/${imagePathFromGameRoot}`
131+
};
119132
}
120133

121-
async function readWorkspacePreviewSvg(manifest, assetFolder) {
122-
const gameRoot = normalizeWorkspacePath(manifest.gameRoot);
123-
if (!gameRoot || !assetFolder) {
124-
return { ok: false, message: "Workspace Manager V2 manifest preview path is incomplete." };
134+
async function validateWorkspacePreviewTarget(previewTargetPath) {
135+
if (!previewTargetPath) {
136+
return { ok: false, message: "Workspace Manager V2 manifest preview target path is empty." };
125137
}
126-
const previewPath = `/${gameRoot}/${assetFolder}/preview.svg`;
127138
try {
128-
const response = await fetch(previewPath, { cache: "no-store" });
139+
const response = await fetch(`/${previewTargetPath}`, { cache: "no-store" });
129140
if (!response.ok) {
130-
return { ok: false, missing: true, previewPath };
141+
return { ok: false, message: `${previewTargetPath} returned ${response.status}.` };
142+
}
143+
const contentType = String(response.headers.get("content-type") || "");
144+
const hasImageExtension = /\.(png|jpe?g|gif|webp|svg)$/i.test(previewTargetPath);
145+
if (contentType && !contentType.startsWith("image/") && !hasImageExtension) {
146+
return { ok: false, message: `${previewTargetPath} is not an image response.` };
131147
}
132-
return { ok: true, previewPath, svgContent: await response.text() };
148+
return { ok: true };
133149
} catch (error) {
134-
return { ok: false, message: `Unable to read ${previewPath}: ${error.message}` };
150+
return { ok: false, message: `Unable to read ${previewTargetPath}: ${error.message}` };
151+
}
152+
}
153+
154+
async function validateRepoRootHandle(handle) {
155+
try {
156+
await PreviewGeneratorV2RepoAccess.getDirectoryHandle(handle, "games");
157+
await PreviewGeneratorV2RepoAccess.getDirectoryHandle(handle, "tools");
158+
return { ok: true };
159+
} catch (error) {
160+
return { ok: false, message: `Selected folder is not the repo root: ${error.message}` };
135161
}
136162
}
137163

@@ -173,6 +199,10 @@ function updateWriteFolderSampleLabel() {
173199
}
174200
}
175201

202+
function updatePreviewTargetLabel() {
203+
ui.outputSummary.setPreviewTarget(workspacePreviewTargetPath || "not available yet");
204+
}
205+
176206
async function updateWriteFolderActualLabelFromInput() {
177207
const lines = parseInputList(ui.pathsOrIds.getValue());
178208
if (!lines.length) {
@@ -195,6 +225,7 @@ async function updateWriteFolderActualLabelFromInput() {
195225

196226
async function updatePathPreviewLabels() {
197227
updateWriteFolderSampleLabel();
228+
updatePreviewTargetLabel();
198229
await updateWriteFolderActualLabelFromInput();
199230
}
200231

@@ -552,13 +583,24 @@ function printSummary(results) {
552583
}
553584

554585
class PreviewGeneratorV2App {
586+
hasValidWorkspacePreviewTarget() {
587+
if (!isWorkspaceManagerLaunch()) {
588+
return true;
589+
}
590+
return workspacePreviewFileValid
591+
&& ui.getSelectedTargetType() === "games"
592+
&& getAssetFolderRelativePath() === workspacePreviewAssetFolder
593+
&& parseInputList(ui.pathsOrIds.getValue()).includes(workspacePreviewGameId);
594+
}
595+
555596
hasRequiredGenerateFields() {
556-
return (Boolean(repoDirHandle) || workspaceLaunchReady)
597+
return Boolean(repoDirHandle)
557598
&& parseInputList(ui.pathsOrIds.getValue()).length > 0
558599
&& ui.targetSource.hasBaseUrl()
559600
&& ui.assetFolder.hasValue()
560601
&& ui.targetSource.hasSelection()
561-
&& ui.captureMode.hasSelection();
602+
&& ui.captureMode.hasSelection()
603+
&& this.hasValidWorkspacePreviewTarget();
562604
}
563605

564606
syncGeneratePreviewButton() {
@@ -567,9 +609,18 @@ class PreviewGeneratorV2App {
567609

568610
async handlePickRepo() {
569611
try {
570-
repoDirHandle = await window.showDirectoryPicker({ mode: "readwrite" });
571-
workspaceLaunchReady = false;
572-
repoDisplayName = PreviewGeneratorV2RepoAccess.getRepoDestinationDisplayName(repoDirHandle);
612+
const selectedRepoHandle = await window.showDirectoryPicker({ mode: "readwrite" });
613+
const repoValidation = await validateRepoRootHandle(selectedRepoHandle);
614+
if (!repoValidation.ok) {
615+
repoDirHandle = null;
616+
repoDisplayName = "";
617+
ui.setRepoDestinationDisplayName("not selected");
618+
this.syncGeneratePreviewButton();
619+
logger.log(`FAIL ${repoValidation.message}`);
620+
return;
621+
}
622+
repoDirHandle = selectedRepoHandle;
623+
repoDisplayName = PreviewGeneratorV2RepoAccess.getRepoDestinationDisplayName(selectedRepoHandle);
573624

574625
ui.setRepoDestinationDisplayName(repoDisplayName);
575626
logger.clearStatus();
@@ -586,12 +637,12 @@ class PreviewGeneratorV2App {
586637
async handleExecute() {
587638
if (!this.hasRequiredGenerateFields()) {
588639
this.syncGeneratePreviewButton();
589-
logger.log("Provide repo destination, base URL, asset folder, and at least one path or ID before generating.");
640+
logger.log("Provide repo destination, base URL, asset folder, preview target, and at least one path or ID before generating.");
590641
return;
591642
}
592643

593644
if (!repoDirHandle) {
594-
logger.log("Workspace launch context is hydrated; Pick Repo Folder is required before writing preview output.");
645+
logger.log("Pick the actual repo root folder before writing preview output.");
595646
return;
596647
}
597648

@@ -678,41 +729,46 @@ class PreviewGeneratorV2App {
678729
}
679730
logger.log("Workspace launch context hydration started.");
680731
if (!contextResult.ok) {
681-
workspaceLaunchReady = false;
732+
workspacePreviewFileValid = false;
682733
logger.log(`FAIL Workspace launch context hydration: ${contextResult.message}`);
683734
this.syncGeneratePreviewButton();
684735
return;
685736
}
686737

687738
const manifest = contextResult.manifest;
688-
const assetFolder = workspaceImageAssetFolder(manifest);
689-
if (!assetFolder) {
690-
workspaceLaunchReady = false;
691-
logger.log("FAIL Workspace launch context hydration: manifest must include an image asset path under assetsPath.");
739+
const previewTarget = workspacePreviewTarget(manifest);
740+
if (!previewTarget.ok) {
741+
workspacePreviewFileValid = false;
742+
logger.log(`FAIL Workspace launch context hydration: ${previewTarget.message}`);
692743
this.syncGeneratePreviewButton();
693744
return;
694745
}
695746

696-
repoDisplayName = `${manifest.gameId} workspace (${normalizeWorkspacePath(manifest.gameRoot)})`;
697-
ui.setRepoDestinationDisplayName(repoDisplayName);
747+
repoDisplayName = "";
748+
repoDirHandle = null;
749+
workspacePreviewAssetFolder = previewTarget.previewAssetFolder;
750+
workspacePreviewGameId = manifest.gameId;
751+
workspacePreviewTargetPath = previewTarget.previewTargetPath;
752+
ui.setRepoDestinationDisplayName("not selected");
753+
ui.repoDestination.setWorkspaceContextLabel(`${manifest.gameId} workspace (${normalizeWorkspacePath(manifest.gameRoot)})`);
698754
ui.targetSource.setSelectedTargetType("games");
699-
ui.assetFolder.setValue(assetFolder);
755+
ui.assetFolder.setValue(workspacePreviewAssetFolder);
700756
ui.pathsOrIds.setValue(manifest.gameId);
701757
await updatePathPreviewLabels();
702-
workspaceLaunchReady = true;
703758
logger.log(`OK Workspace launch context hydrated for ${manifest.gameId}.`);
704-
logger.log(`Repo selected: ${repoDisplayName}`);
759+
logger.log("Repo selected: not selected; pick the actual repo root folder.");
705760
logger.log("Target source: games");
706761
logger.log(`Asset folder: ${getAssetFolderDisplayPath()}`);
762+
logger.log(`Preview target: ${workspacePreviewTargetPath}`);
707763

708-
const previewResult = await readWorkspacePreviewSvg(manifest, assetFolder);
764+
const previewResult = await validateWorkspacePreviewTarget(workspacePreviewTargetPath);
709765
if (previewResult.ok) {
710-
ui.setLastGeneratedImage(previewResult.svgContent, `${manifest.gameId} preview.svg`);
711-
logger.log(`OK Loaded existing preview image from ${previewResult.previewPath}.`);
712-
} else if (previewResult.missing) {
713-
logger.log(`SKIP No existing preview image at ${previewResult.previewPath}.`);
766+
workspacePreviewFileValid = true;
767+
ui.setPreviewTargetImage(workspacePreviewTargetPath);
768+
logger.log(`OK Workspace preview target is valid at ${workspacePreviewTargetPath}.`);
714769
} else {
715-
logger.log(`WARN ${previewResult.message}`);
770+
workspacePreviewFileValid = false;
771+
logger.log(`FAIL Workspace preview target validation: ${previewResult.message}`);
716772
}
717773
this.syncGeneratePreviewButton();
718774
}

tools/preview-generator-v2/PreviewGeneratorV2Ui.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class PreviewGeneratorV2Ui {
4141
this.lastGeneratedImage.setLastGeneratedImage(svgContent, label);
4242
}
4343

44+
setPreviewTargetImage(imagePath) {
45+
this.lastGeneratedImage.setPreviewTargetImage(imagePath);
46+
}
47+
4448
getSelectedCaptureMode() {
4549
return this.captureMode.getSelectedCaptureMode();
4650
}

tools/preview-generator-v2/controls/LastGeneratedImageControl.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class LastGeneratedImageControl {
1313
setLastGeneratedImage(svgContent, label) {
1414
if (this.lastGeneratedImageObjectUrl) {
1515
URL.revokeObjectURL(this.lastGeneratedImageObjectUrl);
16+
this.lastGeneratedImageObjectUrl = "";
1617
}
1718

1819
const blob = new Blob([svgContent], { type: "image/svg+xml" });
@@ -22,6 +23,18 @@ class LastGeneratedImageControl {
2223
this.lastGeneratedImageEmptyEl.hidden = true;
2324
this.lastGeneratedImagePreviewEl.hidden = false;
2425
}
26+
27+
setPreviewTargetImage(imagePath) {
28+
if (this.lastGeneratedImageObjectUrl) {
29+
URL.revokeObjectURL(this.lastGeneratedImageObjectUrl);
30+
this.lastGeneratedImageObjectUrl = "";
31+
}
32+
33+
this.lastGeneratedImageEl.src = `/${imagePath}`;
34+
this.lastGeneratedImageMetaEl.textContent = `Preview target: ${imagePath}`;
35+
this.lastGeneratedImageEmptyEl.hidden = true;
36+
this.lastGeneratedImagePreviewEl.hidden = false;
37+
}
2538
}
2639

2740
export { LastGeneratedImageControl };

0 commit comments

Comments
 (0)