Skip to content

Commit 474f0c6

Browse files
author
DavidQ
committed
Load Active Game dropdown from recursively discovered schema-valid game manifests - PR_26128_006-active-game-manifest-discovery
1 parent 3945514 commit 474f0c6

7 files changed

Lines changed: 415 additions & 33 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# PR_26128_006 Active Game Manifest Discovery
2+
3+
## Changes
4+
- Workspace Manager V2 now starts with an empty, disabled Active Game dropdown until a repo folder is selected.
5+
- Repo Destination selection clears the active workspace immediately before loading the newly selected repo.
6+
- Workspace Manager V2 recursively scans the selected repo `games/` tree for `game.manifest.json` files.
7+
- Discovered manifests are validated through the existing Workspace Manager V2 manifest/schema validation path before being added to Active Game.
8+
- Active Game is populated only with schema-valid discovered manifests and remains unselected after discovery.
9+
- Invalid manifests are skipped with visible status log entries that include the manifest path and validation reason.
10+
- Missing direct child game manifests under `games/` are logged as `SKIP`, not `FAIL`.
11+
- Repo load failure leaves Active Game empty and disabled.
12+
13+
## Guardrails
14+
- No `game.manifest.json` files were modified.
15+
- No sample JSON was modified.
16+
- No schema contracts were modified.
17+
- No cross-tool communication was added.
18+
- No repo write behavior was added or changed.
19+
- No session/toolState persistence behavior was added for repo discovery; discovered repo/game state remains in-memory.
20+
21+
## Validation
22+
- `npm run test:workspace-v2` -> PASS, 11 tests.
23+
- Verified Active Game dropdown is empty and disabled before repo selection.
24+
- Verified dropdown clears when repo selection changes.
25+
- Verified dropdown remains disabled on repo load failure.
26+
- Verified only schema-valid discovered `game.manifest.json` entries appear.
27+
- Verified invalid manifests are skipped and logged with exact path/reason.
28+
- Verified missing manifests are logged as `SKIP`, not `FAIL`.
29+
- Verified no default game is auto-selected after discovery.
30+
31+
## Skipped
32+
- Full samples smoke test was skipped by request. This PR is scoped to Workspace Manager V2 manifest discovery and dropdown state; `npm run test:workspace-v2` plus targeted Playwright coverage exercises the affected UI, discovery, validation, and launch handoff behavior.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Playwright Active Game Manifest Discovery
2+
3+
## Command
4+
- `npm run test:workspace-v2`
5+
6+
## Result
7+
- PASS
8+
- 11 tests passed.
9+
10+
## Coverage Notes
11+
- Added Playwright coverage for repo-backed Active Game discovery.
12+
- The mocked repo picker returns three schema-valid Workspace Manager V2 `game.manifest.json` files, one invalid manifest, and one missing-manifest game folder.
13+
- Assertions verify:
14+
- Active Game is empty and disabled before repo selection.
15+
- Repo selection populates only `Asteroids`, `Gravity Well`, and `Pong`.
16+
- Active Game remains on the placeholder after discovery.
17+
- Invalid manifest log includes `games/InvalidWorkspace/game.manifest.json` and the schema validation reason.
18+
- Missing manifest log includes `games/MissingManifest/game.manifest.json` and is logged as `SKIP`.
19+
- Switching to a repo missing `games/` clears and disables Active Game.
20+
- Re-selecting a valid repo repopulates the dropdown without auto-selecting a game.
21+
- Existing Workspace Manager V2 launch flows still work after discovery.

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 160 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { workspaceV2CoverageReporter as coverageReporter } from "../../helpers/w
55

66
async function openWorkspaceManagerV2(page, query = "") {
77
const server = await startRepoServer();
8+
await installMockRepoPicker(page);
89
await coverageReporter.start(page);
910
await page.goto(`${server.baseUrl}/tools/workspace-manager-v2/index.html${query}`, { waitUntil: "networkidle" });
1011
return server;
@@ -28,6 +29,111 @@ function manifestRepoPath(server) {
2829
return server.repoRoot.replaceAll("\\", "/");
2930
}
3031

32+
async function installMockRepoPicker(page) {
33+
await page.addInitScript(() => {
34+
const defaultManifestPaths = [
35+
"/games/Asteroids/game.manifest.json",
36+
"/games/GravityWell/game.manifest.json",
37+
"/games/Pong/game.manifest.json"
38+
];
39+
40+
function makeFileHandle(name, text) {
41+
return {
42+
kind: "file",
43+
name,
44+
async getFile() {
45+
return new File([text], name, { type: "application/json" });
46+
}
47+
};
48+
}
49+
50+
function makeDirectoryHandle(name, children = {}) {
51+
return {
52+
kind: "directory",
53+
name,
54+
async getDirectoryHandle(childName) {
55+
const child = children[childName];
56+
if (child?.kind === "directory") {
57+
return child;
58+
}
59+
throw new DOMException(`${childName} was not found.`, "NotFoundError");
60+
},
61+
async getFileHandle(childName) {
62+
const child = children[childName];
63+
if (child?.kind === "file") {
64+
return child;
65+
}
66+
throw new DOMException(`${childName} was not found.`, "NotFoundError");
67+
},
68+
async *entries() {
69+
for (const entry of Object.entries(children)) {
70+
yield entry;
71+
}
72+
}
73+
};
74+
}
75+
76+
async function fetchManifestText(path) {
77+
const response = await fetch(path, { cache: "no-store" });
78+
if (!response.ok) {
79+
throw new Error(`${path} returned ${response.status}`);
80+
}
81+
return await response.text();
82+
}
83+
84+
async function makeMockRepoHandle(config = {}) {
85+
const repoName = config.repoName || "HTML-JavaScript-Gaming";
86+
if (config.missingGames) {
87+
return makeDirectoryHandle(repoName, {});
88+
}
89+
const games = {
90+
MissingManifest: makeDirectoryHandle("MissingManifest", {}),
91+
InvalidWorkspace: makeDirectoryHandle("InvalidWorkspace", {
92+
"game.manifest.json": makeFileHandle("game.manifest.json", JSON.stringify({
93+
schema: "html-js-gaming.project",
94+
version: 1,
95+
tools: {}
96+
}))
97+
})
98+
};
99+
for (const manifestPath of (config.manifestPaths || defaultManifestPaths)) {
100+
const parts = manifestPath.replace(/^\/+/, "").split("/");
101+
const gameFolder = parts[1];
102+
games[gameFolder] = makeDirectoryHandle(gameFolder, {
103+
"game.manifest.json": makeFileHandle("game.manifest.json", await fetchManifestText(manifestPath))
104+
});
105+
}
106+
return makeDirectoryHandle(repoName, {
107+
games: makeDirectoryHandle("games", games)
108+
});
109+
}
110+
111+
window.__workspaceManagerV2MockRepoConfig = {};
112+
window.showDirectoryPicker = async () => {
113+
if (window.__workspaceManagerV2MockRepoConfig?.failPicker) {
114+
throw new DOMException("Mock picker canceled.", "AbortError");
115+
}
116+
return await makeMockRepoHandle(window.__workspaceManagerV2MockRepoConfig || {});
117+
};
118+
});
119+
}
120+
121+
async function selectMockRepo(page, { repoName = "HTML-JavaScript-Gaming" } = {}) {
122+
await page.evaluate((nextRepoName) => {
123+
window.__workspaceManagerV2MockRepoConfig = { repoName: nextRepoName };
124+
}, repoName);
125+
await page.locator("#pickRepoBtn").click();
126+
await expect(page.locator("#repoSelectedValue")).toHaveText(repoName);
127+
await expect(page.locator("#activeGameSelect")).toBeEnabled();
128+
await expect(page.locator("#activeGameSelect")).toHaveValue("");
129+
await expect(page.locator("#activeGameSelect option")).toHaveText([
130+
"Select a game",
131+
"Asteroids",
132+
"Gravity Well",
133+
"Pong"
134+
]);
135+
}
136+
31137
test.describe("Workspace Manager V2 bootstrap", () => {
32138
test.afterAll(async () => {
33139
await coverageReporter.writeReport();
@@ -157,6 +263,48 @@ test.describe("Workspace Manager V2 bootstrap", () => {
157263
}
158264
});
159265

266+
test("discovers Active Game options from selected repo manifests", async ({ page }) => {
267+
const server = await openWorkspaceManagerV2(page);
268+
const pageErrors = [];
269+
270+
page.on("pageerror", (error) => {
271+
pageErrors.push(error.message);
272+
});
273+
274+
try {
275+
await expect(page.locator("#activeGameSelect")).toBeDisabled();
276+
await expect(page.locator("#activeGameSelect option")).toHaveCount(0);
277+
await expect(page.locator("#activeGameSummary")).toHaveText("Pick a repo folder to discover schema-valid game manifests.");
278+
279+
await selectMockRepo(page);
280+
await expect(page.locator("#activeGameSummary")).toHaveText("Discovered 3 schema-valid game manifests from HTML-JavaScript-Gaming.");
281+
await expect(page.locator("#statusLog")).toHaveValue(/INFO SKIP games\/InvalidWorkspace\/game\.manifest\.json: Generated Workspace Manager V2 manifest failed schema validation: root\.documentKind is required/);
282+
await expect(page.locator("#statusLog")).toHaveValue(/INFO SKIP games\/MissingManifest\/game\.manifest\.json: game\.manifest\.json not found/);
283+
await expect(page.locator("#statusLog")).toHaveValue(/OK Discovered 3 schema-valid game manifests\./);
284+
285+
await page.evaluate(() => {
286+
window.__workspaceManagerV2MockRepoConfig = {
287+
missingGames: true,
288+
repoName: "BrokenRepo"
289+
};
290+
});
291+
await page.locator("#pickRepoBtn").click();
292+
await expect(page.locator("#repoSelectedValue")).toHaveText("not selected");
293+
await expect(page.locator("#activeGameSelect")).toBeDisabled();
294+
await expect(page.locator("#activeGameSelect option")).toHaveCount(0);
295+
await expect(page.locator("#activeGameSummary")).toHaveText(/Selected repo is missing games\//);
296+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Repo load failed: Selected repo is missing games\//);
297+
298+
await selectMockRepo(page, { repoName: "SecondRepo" });
299+
await expect(page.locator("#activeGameSummary")).toHaveText("Discovered 3 schema-valid game manifests from SecondRepo.");
300+
await expect(page.locator("#workspaceContextOutput")).toHaveValue("{}");
301+
expect(pageErrors).toEqual([]);
302+
} finally {
303+
await coverageReporter.stop(page);
304+
await server.close();
305+
}
306+
});
307+
160308
test("exports manifests and launches tools from fixed Workspace Manager V2 tiles", async ({ page }) => {
161309
const server = await openWorkspaceManagerV2(page);
162310
const pageErrors = [];
@@ -176,6 +324,12 @@ test.describe("Workspace Manager V2 bootstrap", () => {
176324
await expect(page.locator("#workspaceContextContent")).toHaveCount(0);
177325
await expect(page.locator("#workspaceJsonContent #workspaceContextOutput")).toBeVisible();
178326
await expect(page.locator("#copyWorkspaceJsonButton")).toHaveText("copy");
327+
await expect(page.locator("#repoDestinationContent")).toBeVisible();
328+
await expect(page.locator(".workspace-manager-v2__panel--left > .accordion-v2").first().locator(".accordion-v2__header > span:first-child")).toHaveText("Repo Destination");
329+
await expect(page.locator("#pickRepoBtn")).toHaveText("Pick Repo Folder");
330+
await expect(page.locator("#repoSelectedValue")).toHaveText("not selected");
331+
await expect(page.locator("#activeGameSelect")).toBeDisabled();
332+
await expect(page.locator("#activeGameSelect option")).toHaveCount(0);
179333
const centerControlLabels = await page.locator(".workspace-manager-v2__panel--center > .accordion-v2 > .accordion-v2__header > span:first-child")
180334
.evaluateAll((labels) => labels.map((label) => label.textContent.trim()));
181335
expect(centerControlLabels).toEqual(["Tools", "Workspace JSON"]);
@@ -206,6 +360,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
206360
expect(await page.locator("#workspaceToolTiles [data-workspace-tool-id]").evaluateAll((tiles) => (
207361
tiles.every((tile) => Array.from(tile.querySelectorAll(".workspace-manager-v2__tool-tile-action"), (action) => action.textContent.trim()).join("|") === "How To Use|Read Me")
208362
))).toBe(true);
363+
await selectMockRepo(page);
209364
const compactCenterLayout = await page.evaluate(() => {
210365
const getRect = (selector) => {
211366
const element = document.querySelector(selector);
@@ -223,12 +378,6 @@ test.describe("Workspace Manager V2 bootstrap", () => {
223378
});
224379
expect(compactCenterLayout.toolsExtraHeight).toBeLessThanOrEqual(90);
225380
expect(compactCenterLayout.jsonTop).toBeGreaterThan(compactCenterLayout.toolsBottom);
226-
await expect(page.locator("#activeGameSelect option")).toHaveText([
227-
"Select a game",
228-
"Asteroids",
229-
"Gravity Well",
230-
"Pong"
231-
]);
232381

233382
await page.locator("#activeGameSelect").selectOption("Asteroids");
234383
await expect(page.locator("#activeGameSummary")).toContainText("games/Asteroids/");
@@ -678,6 +827,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
678827
Pong: { ok: true }
679828
});
680829

830+
await selectMockRepo(page);
681831
await page.locator("#activeGameSelect").selectOption("GravityWell");
682832
await expect(page.locator("#activeGameSummary")).toContainText("games/GravityWell/");
683833
await expect(page.locator("#workspaceContextOutput")).toHaveValue(/"gameRoot": "games\/GravityWell\/"/);
@@ -712,6 +862,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
712862
await page.locator("#returnToWorkspaceButton").click();
713863
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
714864

865+
await selectMockRepo(page);
715866
await page.locator("#activeGameSelect").selectOption("Pong");
716867
await expect(page.locator("#activeGameSummary")).toContainText("games/Pong/");
717868
await expect(page.locator("#workspaceContextOutput")).toHaveValue(/"gameRoot": "games\/Pong\/"/);
@@ -762,6 +913,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
762913
});
763914

764915
try {
916+
await selectMockRepo(page);
765917
await page.locator("#activeGameSelect").selectOption("Asteroids");
766918
await expect(page.locator("#exportManifestButton")).toBeEnabled();
767919
await page.evaluate(() => {
@@ -793,10 +945,12 @@ test.describe("Workspace Manager V2 bootstrap", () => {
793945
status: 200
794946
});
795947
});
948+
await installMockRepoPicker(page);
796949
await coverageReporter.start(page);
797950
await page.goto(`${server.baseUrl}/tools/workspace-manager-v2/index.html`, { waitUntil: "networkidle" });
798951

799952
try {
953+
await selectMockRepo(page);
800954
await page.locator("#activeGameSelect").selectOption("Asteroids");
801955
await expect(page.locator("#activeAssetRegistrySummary")).toHaveCount(0);
802956
await expect(page.locator("#workspaceContextOutput")).toHaveValue(/"assets": \{\}/);

tools/workspace-manager-v2/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ <h2 class="tools-platform-frame__eyebrow">Games-only launch context</h2>
7373
<span>Game</span>
7474
<select id="activeGameSelect"></select>
7575
</label>
76-
<p id="activeGameSummary" class="workspace-manager-v2__hint">Select a game workspace.</p>
76+
<p id="activeGameSummary" class="workspace-manager-v2__hint">Pick a repo folder to discover schema-valid game manifests.</p>
7777
</div>
7878
</section>
7979
</aside>

tools/workspace-manager-v2/js/WorkspaceManagerV2App.js

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export class WorkspaceManagerV2App {
2121
this.activeGame = null;
2222
this.activeHostContextId = null;
2323
this.activeWorkspaceMode = this.contextService.isUatMode() ? "uat" : "";
24+
this.activeRepoHandle = null;
2425
}
2526

2627
start() {
@@ -62,22 +63,61 @@ export class WorkspaceManagerV2App {
6263
});
6364
this.summary.clear();
6465
this.menu.setExportEnabled(false);
66+
this.gameSelector.clear();
6567
this.toolTiles.renderEmpty();
66-
this.statusLog.ok("Workspace Manager V2 ready. Select, import, or seed a game workspace to create a schema-valid manifest.");
68+
this.statusLog.ok("Workspace Manager V2 ready. Pick a repo folder to discover schema-valid game manifests.");
6769
void this.restoreWorkspaceFromSession();
6870
}
6971

72+
clearActiveWorkspace(summaryText = "Pick a repo folder to discover schema-valid game manifests.") {
73+
this.activeContext = null;
74+
this.activeGame = null;
75+
this.activeHostContextId = null;
76+
this.activeWorkspaceMode = this.contextService.isUatMode() ? "uat" : "";
77+
this.contextService.clearDiscoveredGames();
78+
this.gameSelector.clear();
79+
this.gameSelector.setSummary(summaryText);
80+
this.menu.setExportEnabled(false);
81+
this.toolTiles.renderEmpty();
82+
this.summary.clear();
83+
}
84+
7085
async pickRepoDestination() {
86+
this.activeRepoHandle = null;
87+
this.repoDestination.setRepoDestinationDisplayName("not selected");
88+
this.clearActiveWorkspace("Loading repo game manifests.");
7189
if (typeof window.showDirectoryPicker !== "function") {
72-
this.statusLog.info("Repo folder picker is unavailable in this browser.");
90+
this.gameSelector.setSummary("Repo folder picker is unavailable in this browser.");
91+
this.statusLog.fail("Repo load failed: Repo folder picker is unavailable in this browser.");
7392
return;
7493
}
7594
try {
76-
const selectedRepoHandle = await window.showDirectoryPicker();
95+
const selectedRepoHandle = await window.showDirectoryPicker({ mode: "read" });
7796
const displayName = selectedRepoHandle?.name || "selected";
97+
const discovery = await this.contextService.discoverGameManifests(selectedRepoHandle);
98+
if (!discovery.ok) {
99+
this.gameSelector.setSummary(discovery.message);
100+
this.statusLog.fail(`Repo load failed: ${discovery.message}`);
101+
return;
102+
}
103+
discovery.skips.forEach((skip) => {
104+
this.statusLog.info(`SKIP ${skip.path}: ${skip.reason}`);
105+
});
106+
this.activeRepoHandle = selectedRepoHandle;
78107
this.repoDestination.setRepoDestinationDisplayName(displayName);
79-
this.statusLog.ok(`Repo destination selected: ${displayName}.`);
108+
this.contextService.setDiscoveredGames(discovery.games);
109+
this.gameSelector.setGames(discovery.games);
110+
if (discovery.games.length) {
111+
this.gameSelector.setSummary(`Discovered ${discovery.games.length} schema-valid game manifest${discovery.games.length === 1 ? "" : "s"} from ${displayName}.`);
112+
this.statusLog.ok(`Repo destination selected: ${displayName}.`);
113+
this.statusLog.ok(`Discovered ${discovery.games.length} schema-valid game manifest${discovery.games.length === 1 ? "" : "s"}.`);
114+
} else {
115+
this.gameSelector.clear();
116+
this.gameSelector.setSummary(`No schema-valid game manifests were found in ${displayName}.`);
117+
this.statusLog.fail(`Repo load failed: No schema-valid game.manifest.json files were found in ${displayName}.`);
118+
}
80119
} catch (error) {
120+
this.gameSelector.setSummary("Pick a repo folder to discover schema-valid game manifests.");
81121
this.statusLog.info(`Repo folder selection canceled or failed: ${error.message}`);
82122
}
83123
}

0 commit comments

Comments
 (0)