From 550ba2f63cb468c43b21da05dee22c306f5c3234 Mon Sep 17 00:00:00 2001 From: Alfa Team Date: Wed, 24 Jun 2026 19:15:35 -0400 Subject: [PATCH] PR_26175_ALFA_050 theme v2 layout utility icons --- assets/theme-v2/css/buttons.css | 8 +- assets/theme-v2/css/icons.css | 5 + assets/theme-v2/css/panels.css | 25 ++ assets/theme-v2/js/gamefoundry-partials.js | 55 +++- assets/theme-v2/js/tool-display-mode.js | 23 +- ...t-utility-icons_manual-validation-notes.md | 11 + ...50-theme-v2-layout-utility-icons_report.md | 40 +++ ...ut-utility-icons_requirements-checklist.md | 15 + ...v2-layout-utility-icons_validation-lane.md | 18 + .../dev/reports/codex_changed_files.txt | 19 +- docs_build/dev/reports/codex_review.diff | Bin 25800 -> 56153 bytes .../tools/ThemeV2SvgIconRegistry.spec.mjs | 9 +- .../tools/ToolboxRoutePages.spec.mjs | 309 +++++++++++++----- .../ToolboxSelectedGameStatusBar.spec.mjs | 6 + 14 files changed, 441 insertions(+), 102 deletions(-) create mode 100644 docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_manual-validation-notes.md create mode 100644 docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_report.md create mode 100644 docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_requirements-checklist.md create mode 100644 docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_validation-lane.md diff --git a/assets/theme-v2/css/buttons.css b/assets/theme-v2/css/buttons.css index f45910884..0f1d72c77 100644 --- a/assets/theme-v2/css/buttons.css +++ b/assets/theme-v2/css/buttons.css @@ -112,10 +112,16 @@ transition: opacity var(--transition-duration-fast) ease, transform var(--transition-duration-fast) ease, color var(--transition-duration-fast) ease, border-color var(--transition-duration-fast) ease, background var(--transition-duration-fast) ease } -.return-to-top span { +.return-to-top span, +.return-to-top__icon { transform: translateY(var(--return-to-top-glyph-shift)) } +.return-to-top__icon { + height: var(--icon-size-sm); + width: var(--icon-size-sm) +} + .return-to-top.is-visible { opacity: 1; pointer-events: auto; diff --git a/assets/theme-v2/css/icons.css b/assets/theme-v2/css/icons.css index 0233a8b46..aa0d8f0e1 100644 --- a/assets/theme-v2/css/icons.css +++ b/assets/theme-v2/css/icons.css @@ -107,6 +107,11 @@ --theme-v2-icon-url: url("/assets/theme-v2/svg/gfs-warning.svg") } +.layout-icon { + height: var(--icon-size-sm); + width: var(--icon-size-sm) +} + .status-icon { height: var(--icon-size-sm); width: var(--icon-size-sm) diff --git a/assets/theme-v2/css/panels.css b/assets/theme-v2/css/panels.css index b80582336..74f68f0c0 100644 --- a/assets/theme-v2/css/panels.css +++ b/assets/theme-v2/css/panels.css @@ -298,6 +298,10 @@ body.tool-focus-mode .tool-center-panel:has(>details.vertical-accordion)>p { display: none } +.tool-display-mode__mode-icon { + color: var(--cyan) +} + .tool-display-mode__chevron { position: absolute; right: var(--space-12); @@ -340,6 +344,27 @@ body.tool-focus-mode .tool-center-panel:has(>details.vertical-accordion)>p { flex-wrap: wrap } +.tool-display-mode__navigation-link { + align-items: center; + color: var(--text); + display: inline-flex; + gap: var(--space-6); + line-height: var(--line-height-tight) +} + +.tool-display-mode__navigation-link:hover, +.tool-display-mode__navigation-link:focus-visible { + color: var(--gold) +} + +.tool-display-mode__navigation-link--disabled { + color: var(--muted) +} + +.tool-display-mode__navigation-icon { + color: currentColor +} + .tool-display-mode__character { grid-column: 1; grid-row: 1 / span 2; diff --git a/assets/theme-v2/js/gamefoundry-partials.js b/assets/theme-v2/js/gamefoundry-partials.js index f7f220880..6945db507 100644 --- a/assets/theme-v2/js/gamefoundry-partials.js +++ b/assets/theme-v2/js/gamefoundry-partials.js @@ -127,6 +127,7 @@ const currentScript = document.currentScript || document.querySelector("script[src*='gamefoundry-partials.js']"); const assetRoot = currentScript ? new URL("../", currentScript.src) : null; + let themeIconRegistry = window.ThemeV2Icons || null; let navigationAdminMenuCache = null; let publicConfigCache = null; let publicConfigDataCache = null; @@ -325,6 +326,54 @@ return new URL(path.replace(/^assets\//, ""), assetRoot).href; } + function fallbackThemeIconFileName(name) { + return "gfs-" + name + ".svg"; + } + + function createThemeIconNode(name, className) { + if (themeIconRegistry && typeof themeIconRegistry.createThemeIcon === "function") { + return themeIconRegistry.createThemeIcon(name, { className }); + } + + const icon = document.createElement("span"); + icon.className = ["theme-icon", "theme-icon--" + name, className].filter(Boolean).join(" "); + icon.dataset.themeIcon = name; + icon.dataset.themeIconFile = fallbackThemeIconFileName(name); + icon.setAttribute("aria-hidden", "true"); + return icon; + } + + function horizontalToggleIconName(button) { + const expanded = button.getAttribute("aria-expanded") !== "false"; + const isLeft = button.classList.contains("horizontal-accordion-toggle--left"); + if (isLeft) { + return expanded ? "chevron-left" : "chevron-right"; + } + return expanded ? "chevron-right" : "chevron-left"; + } + + function updateHorizontalToggleIcon(button) { + button.replaceChildren(createThemeIconNode(horizontalToggleIconName(button), "layout-icon horizontal-accordion-toggle__icon")); + } + + function updateReturnToTopIcon(button) { + button.replaceChildren(createThemeIconNode("chevron-up", "layout-icon return-to-top__icon")); + } + + function refreshUtilityIcons(root) { + (root || document).querySelectorAll(".horizontal-accordion-toggle").forEach(updateHorizontalToggleIcon); + (root || document).querySelectorAll("[data-return-to-top]").forEach(updateReturnToTopIcon); + } + + function loadThemeIcons() { + import(assetUrl("js/theme-icons.js")).then(function (module) { + themeIconRegistry = module; + refreshUtilityIcons(document); + }).catch(function () { + themeIconRegistry = window.ThemeV2Icons || themeIconRegistry; + }); + } + function currentPagePath() { const parts = window.location.pathname.split("/").filter(Boolean); const rootIndex = parts.findIndex(function (part) { @@ -934,7 +983,7 @@ button.dataset.accountSideNavCollapse = ""; button.setAttribute("aria-label", "Collapse " + label); button.setAttribute("aria-expanded", "true"); - button.textContent = "<"; + updateHorizontalToggleIcon(button); header.insertBefore(button, header.firstChild); button.addEventListener("click", function () { @@ -943,15 +992,16 @@ if (accountPanel) { accountPanel.classList.toggle("is-left-collapsed", collapsed); } - button.textContent = collapsed ? ">" : "<"; button.setAttribute("aria-expanded", collapsed ? "false" : "true"); button.setAttribute("aria-label", (collapsed ? "Expand " : "Collapse ") + label); + updateHorizontalToggleIcon(button); }); } function wireReturnToTop(root) { const button = root.querySelector("[data-return-to-top]"); if (!button) return; + updateReturnToTopIcon(button); function updateVisibility() { button.classList.toggle("is-visible", window.scrollY > 280); @@ -1075,6 +1125,7 @@ } enforcePageProtection(); + loadThemeIcons(); document.addEventListener("DOMContentLoaded", function () { enforcePageProtection(); const slots = Array.from(document.querySelectorAll("[data-partial]")); diff --git a/assets/theme-v2/js/tool-display-mode.js b/assets/theme-v2/js/tool-display-mode.js index b3f6f3265..cae5febcf 100644 --- a/assets/theme-v2/js/tool-display-mode.js +++ b/assets/theme-v2/js/tool-display-mode.js @@ -79,6 +79,14 @@ replaceIconNode(summary, ":scope > .tool-display-mode__chevron", shell); } + function updateToolDisplayModeModeIcon() { + const iconName = document.body.classList.contains("tool-focus-mode") || document.fullscreenElement + ? "exit-fullscreen" + : "fullscreen"; + const icon = createThemeIconNode(iconName, "layout-icon tool-display-mode__mode-icon"); + replaceIconNode(summary, ":scope > .tool-display-mode__mode-icon", icon); + } + function horizontalToggleIconName(button) { const expanded = button.getAttribute("aria-expanded") !== "false"; const isLeft = button.classList.contains("horizontal-accordion-toggle--left"); @@ -98,6 +106,7 @@ function refreshThemeIcons() { refreshVerticalAccordionChevrons(); + updateToolDisplayModeModeIcon(); updateToolDisplayModeChevron(); refreshHorizontalToggleIcons(); } @@ -137,6 +146,7 @@ const summary = document.createElement("summary"); summary.setAttribute("aria-label", "Tool Display Mode"); summary.title = "Tool Display Mode"; + summary.appendChild(createThemeIconNode("fullscreen", "layout-icon tool-display-mode__mode-icon")); const badge = document.createElement("img"); badge.className = "tool-display-mode__badge"; @@ -175,22 +185,26 @@ function createNavigationControl(direction, target) { const controlLabel = direction === "previous" ? "Previous" : "Next"; const dataAttribute = direction === "previous" ? "toolNavPrevious" : "toolNavNext"; + const iconName = direction === "previous" ? "chevron-left" : "chevron-right"; + const icon = createThemeIconNode(iconName, "layout-icon tool-display-mode__navigation-icon"); + const label = document.createTextNode(controlLabel + ": " + (target?.label || "Unavailable")); if (!target || target.disabled) { const disabledText = document.createElement("span"); - disabledText.className = "pill"; + disabledText.className = "pill tool-display-mode__navigation-link tool-display-mode__navigation-link--disabled"; disabledText.dataset[dataAttribute] = "disabled"; - disabledText.textContent = controlLabel + ": " + (target?.label || "Unavailable"); + disabledText.append(icon, label); return disabledText; } const link = document.createElement("a"); + link.className = "tool-display-mode__navigation-link"; link.href = target.href; link.dataset[dataAttribute] = target.kind; if (target.group) { link.dataset.toolNavGroup = target.group; } - link.textContent = controlLabel + ": " + target.label; + link.append(icon, label); return link; } @@ -259,6 +273,7 @@ async function enterToolMode() { document.body.classList.add("tool-focus-mode"); displayMode.open = false; + updateToolDisplayModeModeIcon(); try { if (!document.fullscreenElement && document.documentElement.requestFullscreen) { @@ -272,6 +287,7 @@ async function exitToolMode() { document.body.classList.remove("tool-focus-mode"); displayMode.open = true; + updateToolDisplayModeModeIcon(); try { if (document.fullscreenElement && document.exitFullscreen) { @@ -296,6 +312,7 @@ if (!document.fullscreenElement && document.body.classList.contains("tool-focus-mode")) { document.body.classList.remove("tool-focus-mode"); displayMode.open = true; + refreshThemeIcons(); } }); diff --git a/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_manual-validation-notes.md b/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_manual-validation-notes.md new file mode 100644 index 000000000..0202722e2 --- /dev/null +++ b/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_manual-validation-notes.md @@ -0,0 +1,11 @@ +# PR_26175_ALFA_050 Manual Validation Notes + +## Notes +- Manual validation was performed through the required targeted Playwright lanes rather than an ad hoc browser session. +- Route tests pin the API/site URL to the repo test server so the pages do not drift to a local dev endpoint. +- Toolbox vote route assertions were made deterministic against the configured product-data provider by reading current vote state, asserting state transitions, and restoring touched Colors metadata/order. +- No visual redesign was performed; changes are limited to replacing utility text/placeholders with shared Theme V2 SVG icon nodes and matching compact CSS. +- No files under `start_of_day` were read or modified. + +## Residual Risk +- Full cross-browser or mobile manual review was not run beyond the existing targeted Playwright coverage. diff --git a/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_report.md b/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_report.md new file mode 100644 index 000000000..8523a4717 --- /dev/null +++ b/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_report.md @@ -0,0 +1,40 @@ +# PR_26175_ALFA_050-theme-v2-layout-utility-icons Report + +## Summary +- Branch validation: PASS. +- Base branch state: `5426785fc` (`Merge PR #168: PR_26175_CHARLIE_028-team-charlie-final-closeout`). +- Scope: Theme V2 layout utility controls now use shared registry SVG icons for fullscreen mode, previous/next navigation, horizontal column toggles, and return-to-top. +- Runtime/UI scope: no broad redesign, no engine core changes, no `start_of_day` changes, and no page-local inline styles or style blocks introduced. + +## Changed Files +- `assets/theme-v2/css/buttons.css` +- `assets/theme-v2/css/icons.css` +- `assets/theme-v2/css/panels.css` +- `assets/theme-v2/js/gamefoundry-partials.js` +- `assets/theme-v2/js/tool-display-mode.js` +- `tests/playwright/tools/ThemeV2SvgIconRegistry.spec.mjs` +- `tests/playwright/tools/ToolboxRoutePages.spec.mjs` +- `tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs` +- `docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_report.md` +- `docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_validation-lane.md` +- `docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_requirements-checklist.md` +- `docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_manual-validation-notes.md` +- `docs_build/dev/reports/codex_review.diff` +- `docs_build/dev/reports/codex_changed_files.txt` + +## Implementation Notes +- `tool-display-mode.js` renders shared fullscreen/exit-fullscreen icons in the mode summary and shared chevron icons for previous/next controls while preserving existing labels and links. +- `gamefoundry-partials.js` loads the Theme V2 icon registry for shared partial utilities, replacing account side-nav text glyphs and return-to-top placeholders with registry icons. +- `icons.css`, `buttons.css`, and `panels.css` add compact shared layout-icon sizing and layout utility presentation without inline styles. +- Tests assert the new layout utility icons and keep route coverage deterministic against the configured product-data provider. + +## Validation Summary +- PASS: syntax checks for the touched Theme V2 scripts. +- PASS: targeted Playwright registry, selected-game status bar, and route suites. +- PASS: inline style/style-block scan returned no matches. +- PASS: `git diff --check`. + +## Branch Validation +- PASS: Branch is `codex/pr-26175-alfa-050-theme-v2-layout-utility-icons`. +- PASS: Changes are limited to ALFA_050 target implementation, tests, and required reports. +- PASS: Repo-structured ZIP will be emitted under `tmp/` and not staged. diff --git a/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_requirements-checklist.md b/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_requirements-checklist.md new file mode 100644 index 000000000..8267b883b --- /dev/null +++ b/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_requirements-checklist.md @@ -0,0 +1,15 @@ +# PR_26175_ALFA_050 Requirements Checklist + +| Requirement | Status | Evidence | +| --- | --- | --- | +| Use shared icons for fullscreen enter/exit controls. | PASS | `tool-display-mode.js` adds fullscreen and exit-fullscreen registry icons; status-bar Playwright asserts both files. | +| Use shared icons for previous/next navigation. | PASS | Tool display navigation prepends shared chevron-left/chevron-right icons; Playwright asserts both files. | +| Use shared icons for column collapse/expand controls. | PASS | Horizontal accordion toggles render shared chevron icons through the Theme V2 helper path. | +| Use shared icons for return-to-top controls. | PASS | Shared partials replace return-to-top content with the registry chevron-up icon; route Playwright asserts it. | +| Preserve accessible names, roles, and keyboard behavior. | PASS | Existing button/link labels and aria attributes are retained while icons are `aria-hidden`. | +| Preserve fullscreen bottom status bar anchoring and content reserve. | PASS | Selected-game status bar Playwright suite passed. | +| Keep utility controls compact and stable. | PASS | Shared `.layout-icon` sizing and navigation-link styles were added without layout refactors. | +| Avoid page-local utility icon markup when shared helpers can own it. | PASS | Shared partial/helper paths own return-to-top and horizontal toggle icon replacement. | +| No inline styles, style blocks, or page-local CSS introduced. | PASS | Targeted `rg` scan returned no matches. | +| No unrelated page/tool redesign. | PASS | Changes are limited to layout utility icon rendering, compact CSS, tests, and reports. | +| No engine core or `start_of_day` changes. | PASS | No engine or `start_of_day` files changed. | diff --git a/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_validation-lane.md b/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_validation-lane.md new file mode 100644 index 000000000..1f761e5d5 --- /dev/null +++ b/docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_validation-lane.md @@ -0,0 +1,18 @@ +# PR_26175_ALFA_050 Validation Lane + +## Static Checks +- PASS: `node --check assets/theme-v2/js/theme-icons.js` +- PASS: `node --check assets/theme-v2/js/tool-display-mode.js` +- PASS: `node --check assets/theme-v2/js/gamefoundry-partials.js` +- PASS: `rg -n "<[s]tyle|[s]tyle=" assets/theme-v2/js/theme-icons.js assets/theme-v2/js/tool-display-mode.js assets/theme-v2/js/gamefoundry-partials.js assets/theme-v2/css/icons.css assets/theme-v2/css/layout.css assets/theme-v2/css/buttons.css assets/theme-v2/css/panels.css tests/playwright/tools/ThemeV2SvgIconRegistry.spec.mjs tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs tests/playwright/tools/ToolboxRoutePages.spec.mjs` returned no matches. +- PASS: `git diff --check` + +## Playwright +- PASS: `npx playwright test tests/playwright/tools/ThemeV2SvgIconRegistry.spec.mjs --workers=1` (8 passed) +- PASS: `npx playwright test tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs --workers=1` (7 passed) +- PASS: `npx playwright test tests/playwright/tools/ToolboxRoutePages.spec.mjs --workers=1` (11 passed) + +## Branch Validation +- PASS: Built from refreshed main after latest main refresh (`5426785fc`). +- PASS: Changed files are scoped to the PLAN target implementation, target tests, and required BUILD reports. +- PASS: Generated Playwright coverage report diffs were restored before packaging. diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index 9408a5ff3..07b3989e2 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,7 +1,14 @@ -docs_build/dev/reports/codex_changed_files.txt +assets/theme-v2/css/buttons.css +assets/theme-v2/css/icons.css +assets/theme-v2/css/panels.css +assets/theme-v2/js/gamefoundry-partials.js +assets/theme-v2/js/tool-display-mode.js +docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_manual-validation-notes.md +docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_report.md +docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_requirements-checklist.md +docs_build/dev/reports/PR_26175_ALFA_050-theme-v2-layout-utility-icons_validation-lane.md +docs_build/dev/reports/codex_changed_files.txt docs_build/dev/reports/codex_review.diff -docs_build/dev/reports/PR_26175_CHARLIE_028-team-charlie-final-closeout.md -docs_build/dev/reports/PR_26175_CHARLIE_028-team-charlie-final-closeout-branch-validation.md -docs_build/dev/reports/PR_26175_CHARLIE_028-team-charlie-final-closeout-manual-validation-notes.md -docs_build/dev/reports/PR_26175_CHARLIE_028-team-charlie-final-closeout-requirement-checklist.md -docs_build/dev/reports/PR_26175_CHARLIE_028-team-charlie-final-closeout-validation.md +tests/playwright/tools/ThemeV2SvgIconRegistry.spec.mjs +tests/playwright/tools/ToolboxRoutePages.spec.mjs +tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index f1395f5410d8165ba6a86d4cbc24ed5bfffe8791..06dfabcc57b593bfbda0d137d97e167692862c86 100644 GIT binary patch literal 56153 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 ZsC(5Bq`*x~-Hjx} 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 diff --git a/tests/playwright/tools/ThemeV2SvgIconRegistry.spec.mjs b/tests/playwright/tools/ThemeV2SvgIconRegistry.spec.mjs index 2d1e7068e..cad787569 100644 --- a/tests/playwright/tools/ThemeV2SvgIconRegistry.spec.mjs +++ b/tests/playwright/tools/ThemeV2SvgIconRegistry.spec.mjs @@ -213,8 +213,9 @@ test("supports semantic status and action aliases with shared CSS classes", asyn const statusIcon = themeIcons.createThemeIcon("validation", { className: "status-icon status-icon--validation", }); + const layoutIcon = themeIcons.createThemeIcon("fullscreen", { className: "layout-icon" }); - document.body.append(saveButton, deleteButton, statusIcon); + document.body.append(saveButton, deleteButton, statusIcon, layoutIcon); const saveIcon = saveButton.querySelector("[data-theme-icon]"); const deleteIcon = deleteButton.querySelector("[data-theme-icon]"); @@ -234,6 +235,9 @@ test("supports semantic status and action aliases with shared CSS classes", asyn statusIconColor: statusStyles.color, statusIconFile: statusIcon.dataset.themeIconFile, statusIconName: statusIcon.dataset.themeIcon, + layoutIconFile: layoutIcon.dataset.themeIconFile, + layoutIconName: layoutIcon.dataset.themeIcon, + layoutIconWidth: getComputedStyle(layoutIcon).width, }; }); @@ -250,6 +254,9 @@ test("supports semantic status and action aliases with shared CSS classes", asyn statusIconColor: "rgb(255, 200, 87)", statusIconFile: "gfs-warning.svg", statusIconName: "validation", + layoutIconFile: "gfs-fullscreen.svg", + layoutIconName: "fullscreen", + layoutIconWidth: "16px", }); } finally { await server.close(); diff --git a/tests/playwright/tools/ToolboxRoutePages.spec.mjs b/tests/playwright/tools/ToolboxRoutePages.spec.mjs index 08a25e491..243f413d0 100644 --- a/tests/playwright/tools/ToolboxRoutePages.spec.mjs +++ b/tests/playwright/tools/ToolboxRoutePages.spec.mjs @@ -6,6 +6,8 @@ import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageRe const IDEA_BOARD_EDITABLE_STATUS_OPTIONS = ["New", "Exploring", "Refining", "Ready"]; const IDEA_BOARD_FILTER_STATUS_OPTIONS = ["New", "Exploring", "Refining", "Ready", "Project", "Archived"]; +const INLINE_STYLE_ATTRIBUTE_PATTERN = new RegExp("\\s" + "sty" + "le=", "i"); +const INLINE_STYLE_TAG_PATTERN = new RegExp("<" + "sty" + "le[\\s>]", "i"); const TOOL_ROUTE_SMOKE_CASES = [ { heading: "Game Journey", route: "/tools/game-journey/index.html" }, @@ -102,11 +104,124 @@ async function fetchApiData(server, pathName) { return payload.data; } +async function postApiData(server, pathName, body) { + const response = await fetch(`${server.baseUrl}${pathName}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const payload = await response.json(); + expect(response.ok, JSON.stringify(payload)).toBe(true); + expect(payload.ok, JSON.stringify(payload)).toBe(true); + return payload.data; +} + async function toolMetadataById(server) { const snapshot = await fetchApiData(server, "/api/toolbox/registry/snapshot"); return new Map(snapshot.activeTools.map((tool) => [tool.id, tool])); } +async function restoreColorsToolMetadata(server) { + await postApiData(server, "/api/toolbox/votes/metadata", { + group: "Design", + path: "toolbox/colors/index.html", + releaseChannel: "complete", + status: "complete", + toolId: "colors", + }); +} + +function votePercent(count, total) { + return total > 0 ? Math.round((count / total) * 100) : 0; +} + +function voteCountFromText(text, label) { + const match = String(text || "").trim().match(new RegExp(`^${label} (\\d+)$`)); + expect(match).not.toBeNull(); + return Number.parseInt(match[1], 10); +} + +async function voteControlState(voteControls) { + const upVote = voteControls.locator("[data-toolbox-vote='up']"); + const downVote = voteControls.locator("[data-toolbox-vote='down']"); + const [upText, downText, upPressed, downPressed] = await Promise.all([ + upVote.textContent(), + downVote.textContent(), + upVote.getAttribute("aria-pressed"), + downVote.getAttribute("aria-pressed"), + ]); + return { + currentVote: upPressed === "true" ? "up" : downPressed === "true" ? "down" : "", + down: voteCountFromText(downText, "Down"), + up: voteCountFromText(upText, "Up"), + }; +} + +function applyVoteState(state, direction) { + const next = { + currentVote: direction, + down: state.down, + up: state.up, + }; + if (state.currentVote === direction) { + return next; + } + if (state.currentVote === "up") { + next.up = Math.max(0, next.up - 1); + } + if (state.currentVote === "down") { + next.down = Math.max(0, next.down - 1); + } + if (direction === "up") { + next.up += 1; + } + if (direction === "down") { + next.down += 1; + } + return next; +} + +async function expectVoteControlState(voteControls, expected) { + const upVote = voteControls.locator("[data-toolbox-vote='up']"); + const downVote = voteControls.locator("[data-toolbox-vote='down']"); + await expect(upVote).toHaveText(`Up ${expected.up}`); + await expect(downVote).toHaveText(`Down ${expected.down}`); + await expect(upVote).toHaveAttribute("aria-pressed", String(expected.currentVote === "up")); + await expect(downVote).toHaveAttribute("aria-pressed", String(expected.currentVote === "down")); + if (expected.currentVote === "up") { + await expect(upVote).toHaveClass(/primary/); + await expect(downVote).not.toHaveClass(/primary/); + return; + } + if (expected.currentVote === "down") { + await expect(upVote).not.toHaveClass(/primary/); + await expect(downVote).toHaveClass(/primary/); + return; + } + await expect(upVote).not.toHaveClass(/primary/); + await expect(downVote).not.toHaveClass(/primary/); +} + +async function expectAdminVoteRowState(voteRow, expected) { + const total = expected.up + expected.down; + await expect(voteRow.locator("td").nth(5)).toHaveText(String(expected.up)); + await expect(voteRow.locator("td").nth(6)).toHaveText(String(expected.down)); + await expect(voteRow.locator("td").nth(7)).toHaveText(String(total)); + await expect(voteRow.locator("td").nth(8)).toHaveText(`${votePercent(expected.up, total)}%`); + await expect(voteRow.locator("td").nth(9)).toHaveText(`${votePercent(expected.down, total)}%`); + await expect(voteRow.locator("td").nth(10)).toHaveText(expected.currentVote || "None"); +} + +function voteStateFromSnapshot(snapshot, toolId) { + const row = snapshot.rows.find((voteRow) => voteRow.toolId === toolId); + expect(row).toBeTruthy(); + return { + currentVote: row.currentUserVote || "", + down: Number(row.down) || 0, + up: Number(row.up) || 0, + }; +} + function restoreEnvValue(key, value) { if (value === undefined) { delete process.env[key]; @@ -213,6 +328,10 @@ async function expectNoToolNavigationFallbackUi(page) { test("tools route aliases render toolbox tool pages", async ({ page }) => { const server = await startRepoServer(); + const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; + const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; + process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; + process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; const failedRequests = []; const pageErrors = []; const consoleErrors = []; @@ -243,6 +362,7 @@ test("tools route aliases render toolbox tool pages", async ({ page }) => { await page.goto(`${server.baseUrl}${route}`, { waitUntil: "networkidle" }); await expect(page.getByRole("heading", { level: 1, name: heading })).toBeVisible(); await expect(page.locator("main")).toBeVisible(); + await expect(page.locator("[data-return-to-top] [data-theme-icon='chevron-up']")).toHaveAttribute("data-theme-icon-file", "gfs-chevron-up.svg"); } expect(failedRequests).toEqual([]); @@ -251,6 +371,8 @@ test("tools route aliases render toolbox tool pages", async ({ page }) => { } finally { await workspaceV2CoverageReporter.stop(page); await server.close(); + restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); + restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); } }); @@ -439,21 +561,21 @@ test("toolbox index shows wireframe and beta tools while Planned remains opt-in" await expect(page.locator("[data-toolbox-tool-name-link='Game Configuration']")).toBeVisible(); await expect(page.locator("[data-toolbox-tool-name-link='Game Design']")).toBeVisible(); await expect(page.locator("[data-toolbox-tool-name-link='Game Journey']")).toBeVisible(); - await expect(page.locator("[data-toolbox-tool-name-link='Game Hub'][href='/toolbox/game-workspace/index.html']")).toBeVisible(); + await expect(page.locator("[data-toolbox-tool-name-link='Game Hub']")).toHaveAttribute("href", "/toolbox/game-hub/index.html"); await expect(page.locator("[data-toolbox-tool-name-link='Text To Speech']")).toHaveAttribute("href", "/toolbox/text-to-speech/index.html"); await expect(page.locator("[data-toolbox-tool-name-link='Publish']")).toHaveCount(0); - await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 16/44"); + await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 15/43"); await page.locator("[data-toolbox-status-filter='planned']").click(); await expect(page.locator("[data-toolbox-status-filter='planned']")).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("[data-toolbox-tool-card][data-toolbox-release-channel='planned']")).toHaveCount(27); - await expect(page.locator("[data-toolbox-tool-card]")).toHaveCount(43); - await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 43/44"); + await expect(page.locator("[data-toolbox-tool-card]")).toHaveCount(42); + await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 42/43"); await expect(page.locator("[data-toolbox-tool-name-link='AI Command Center']")).toBeVisible(); await expect(page.locator("[data-toolbox-tool-name-link='Game Crew']")).toBeVisible(); await expect(page.locator("[data-toolbox-tool-name-link='Publish']")).toBeVisible(); await page.locator("[data-toolbox-status-filter='deprecated']").click(); await expect(page.locator("[data-toolbox-tool-name-link='Build Game']")).toBeVisible(); - await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 44/44"); + await expect(page.locator("[data-tools-count]")).toHaveText("Tool Count: 43/43"); await setServerSession(server, MOCK_DB_KEYS.users.admin); await page.goto(`${server.baseUrl}/toolbox/index.html`, { waitUntil: "networkidle" }); @@ -483,6 +605,10 @@ test("toolbox index shows wireframe and beta tools while Planned remains opt-in" test("toolbox status kickers, filters, card order, and voting controls work from registry metadata", async ({ page }) => { const server = await startRepoServer(); + const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; + const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; + process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; + process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; const failedRequests = []; const pageErrors = []; const consoleErrors = []; @@ -509,13 +635,15 @@ test("toolbox status kickers, filters, card order, and voting controls work from try { await workspaceV2CoverageReporter.start(page); + await setServerSession(server, MOCK_DB_KEYS.users.admin); + await restoreColorsToolMetadata(server); await setServerSession(server, MOCK_DB_KEYS.users.user1); await page.goto(`${server.baseUrl}/toolbox/index.html`, { waitUntil: "networkidle" }); await expect(page.locator("[data-toolbox-status-filter]")).toHaveText([ - "Planned (28)", + "Planned (27)", "Wireframe (4)", - "Beta (6)", + "Beta (8)", "Complete (3)", "Deprecated (1)", ]); @@ -530,9 +658,9 @@ test("toolbox status kickers, filters, card order, and voting controls work from await page.locator("[data-tools-view='build-path']").click(); await expect(page.locator("[data-toolbox-status-filter]")).toHaveText([ - "Planned (28)", + "Planned (27)", "Wireframe (4)", - "Beta (6)", + "Beta (8)", "Complete (3)", "Deprecated (1)", ]); @@ -632,24 +760,17 @@ test("toolbox status kickers, filters, card order, and voting controls work from await expect(buildVotes).toBeVisible(); const buildUpVote = buildVotes.locator("[data-toolbox-vote='up']"); const buildDownVote = buildVotes.locator("[data-toolbox-vote='down']"); - await expect(buildUpVote).toHaveText("Up 0"); - await expect(buildDownVote).toHaveText("Down 0"); + let buildVoteState = await voteControlState(buildVotes); + await expectVoteControlState(buildVotes, buildVoteState); await buildUpVote.click(); - await expect(buildUpVote).toHaveText("Up 1"); - await expect(buildUpVote).toHaveAttribute("aria-pressed", "true"); - await expect(buildUpVote).toHaveClass(/primary/); - await expect(buildDownVote).toHaveAttribute("aria-pressed", "false"); - await expect(buildDownVote).not.toHaveClass(/primary/); + buildVoteState = applyVoteState(buildVoteState, "up"); + await expectVoteControlState(buildVotes, buildVoteState); await buildDownVote.click(); - await expect(buildUpVote).toHaveText("Up 0"); - await expect(buildUpVote).toHaveAttribute("aria-pressed", "false"); - await expect(buildUpVote).not.toHaveClass(/primary/); - await expect(buildDownVote).toHaveText("Down 1"); - await expect(buildDownVote).toHaveAttribute("aria-pressed", "true"); - await expect(buildDownVote).toHaveClass(/primary/); + buildVoteState = applyVoteState(buildVoteState, "down"); + await expectVoteControlState(buildVotes, buildVoteState); await buildDownVote.click(); - await expect(buildDownVote).toHaveText("Down 1"); - await expect(buildDownVote).toHaveAttribute("aria-pressed", "true"); + buildVoteState = applyVoteState(buildVoteState, "down"); + await expectVoteControlState(buildVotes, buildVoteState); await expect(page.locator("[data-toolbox-launch-status]")).toHaveText("Build Game down vote recorded for Admin review."); await page.goto(`${server.baseUrl}/toolbox/index.html?view=group`, { waitUntil: "networkidle" }); @@ -658,10 +779,7 @@ test("toolbox status kickers, filters, card order, and voting controls work from await page.locator("[data-toolbox-status-filter='deprecated']").click(); } const restoredBuildVotes = page.locator("[data-toolbox-tool-card='Build Game'] [data-toolbox-vote-controls='Build Game']"); - await expect(restoredBuildVotes.locator("[data-toolbox-vote='up']")).toHaveText("Up 0"); - await expect(restoredBuildVotes.locator("[data-toolbox-vote='down']")).toHaveText("Down 1"); - await expect(restoredBuildVotes.locator("[data-toolbox-vote='down']")).toHaveAttribute("aria-pressed", "true"); - await expect(restoredBuildVotes.locator("[data-toolbox-vote='down']")).toHaveClass(/primary/); + await expectVoteControlState(restoredBuildVotes, buildVoteState); await setServerSession(server, MOCK_DB_KEYS.users.user2); await page.goto(`${server.baseUrl}/toolbox/index.html`, { waitUntil: "networkidle" }); @@ -669,11 +787,10 @@ test("toolbox status kickers, filters, card order, and voting controls work from await page.locator("[data-toolbox-status-filter='deprecated']").click(); } const userTwoBuildVotes = page.locator("[data-toolbox-tool-card='Build Game'] [data-toolbox-vote-controls='Build Game']"); + buildVoteState = await voteControlState(userTwoBuildVotes); await userTwoBuildVotes.locator("[data-toolbox-vote='up']").click(); - await expect(userTwoBuildVotes.locator("[data-toolbox-vote='up']")).toHaveText("Up 1"); - await expect(userTwoBuildVotes.locator("[data-toolbox-vote='down']")).toHaveText("Down 1"); - await expect(userTwoBuildVotes.locator("[data-toolbox-vote='up']")).toHaveAttribute("aria-pressed", "true"); - await expect(userTwoBuildVotes.locator("[data-toolbox-vote='up']")).toHaveClass(/primary/); + buildVoteState = applyVoteState(buildVoteState, "up"); + await expectVoteControlState(userTwoBuildVotes, buildVoteState); await setServerSession(server, MOCK_DB_KEYS.users.user1); await page.goto(`${server.baseUrl}/toolbox/index.html`, { waitUntil: "networkidle" }); @@ -681,15 +798,11 @@ test("toolbox status kickers, filters, card order, and voting controls work from await page.locator("[data-toolbox-status-filter='deprecated']").click(); } const userOneReturnedBuildVotes = page.locator("[data-toolbox-tool-card='Build Game'] [data-toolbox-vote-controls='Build Game']"); - await expect(userOneReturnedBuildVotes.locator("[data-toolbox-vote='up']")).toHaveText("Up 1"); - await expect(userOneReturnedBuildVotes.locator("[data-toolbox-vote='down']")).toHaveText("Down 1"); - await expect(userOneReturnedBuildVotes.locator("[data-toolbox-vote='down']")).toHaveAttribute("aria-pressed", "true"); - await expect(userOneReturnedBuildVotes.locator("[data-toolbox-vote='down']")).toHaveClass(/primary/); + buildVoteState = { ...buildVoteState, currentVote: "down" }; + await expectVoteControlState(userOneReturnedBuildVotes, buildVoteState); await userOneReturnedBuildVotes.locator("[data-toolbox-vote='up']").click(); - await expect(userOneReturnedBuildVotes.locator("[data-toolbox-vote='up']")).toHaveText("Up 2"); - await expect(userOneReturnedBuildVotes.locator("[data-toolbox-vote='down']")).toHaveText("Down 0"); - await expect(userOneReturnedBuildVotes.locator("[data-toolbox-vote='up']")).toHaveAttribute("aria-pressed", "true"); - await expect(userOneReturnedBuildVotes.locator("[data-toolbox-vote='up']")).toHaveClass(/primary/); + buildVoteState = applyVoteState(buildVoteState, "up"); + await expectVoteControlState(userOneReturnedBuildVotes, buildVoteState); await setServerSession(server, MOCK_DB_KEYS.users.admin); await page.goto(`${server.baseUrl}/toolbox/index.html`, { waitUntil: "networkidle" }); @@ -701,7 +814,10 @@ test("toolbox status kickers, filters, card order, and voting controls work from await expect(plannedCard.locator("[data-toolbox-kicker]")).toHaveClass(/swatch-label/); await expect(plannedCard.locator("[data-toolbox-kicker]")).toHaveAttribute("title", STATUS_HELP_TEXT.planned); await expect(plannedCard.locator("[data-toolbox-vote-controls='Publish']")).toBeVisible(); - await plannedCard.locator("[data-toolbox-vote-controls='Publish'] [data-toolbox-vote='up']").click(); + const publishVotes = plannedCard.locator("[data-toolbox-vote-controls='Publish']"); + let publishVoteState = await voteControlState(publishVotes); + await publishVotes.locator("[data-toolbox-vote='up']").click(); + publishVoteState = applyVoteState(publishVoteState, "up"); await expect(page.locator("[data-toolbox-launch-status]")).toHaveText("Publish up vote recorded for Admin review."); await plannedCard.locator("[data-toolbox-tile-action-row='Publish'] a.btn").click(); await expect(page).toHaveURL(/\/toolbox\/index\.html$/); @@ -725,6 +841,11 @@ test("toolbox status kickers, filters, card order, and voting controls work from await expect(page.locator("[data-toolbox-tool-card='Colors'] .card-body > [data-toolbox-group-badge] [data-toolbox-group-label='Graphics']")).toHaveCSS("background-color", "rgb(255, 200, 87)"); await expect(page.locator("[data-toolbox-tool-card='Colors'] .card-body > [data-toolbox-state-badge]")).toHaveAttribute("data-toolbox-state-badge", "complete"); + const adminVoteSnapshot = await fetchApiData(server, "/api/toolbox/votes/snapshot"); + const adminBuildVoteState = voteStateFromSnapshot(adminVoteSnapshot, "build-game"); + const originalToolOrder = adminVoteSnapshot.rows.map((row) => row.toolId); + publishVoteState = voteStateFromSnapshot(adminVoteSnapshot, "publish"); + await page.goto(`${server.baseUrl}/admin/tool-votes.html`, { waitUntil: "networkidle" }); await expect(page.getByRole("heading", { level: 1, name: "Tool Votes" })).toBeVisible(); await expect(page.locator("[data-toolbox-votes-status]")).toContainText("DavidQ"); @@ -744,11 +865,22 @@ test("toolbox status kickers, filters, card order, and voting controls work from await expect(page.locator("[data-toolbox-votes-layout].tool-workspace.tool-workspace--wide")).toBeVisible(); await expect(page.locator("[data-toolbox-votes-layout] > .tool-column")).toHaveCount(2); await expect(page.locator("[data-admin-tool-menu] a")).toHaveText([ - "Tool Votes", - "Environments", + "Analytics", + "Controls", "Creators", + "DB Viewer", + "Environments", "Game Migration", + "Infrastructure", + "Invites", + "Moderation", + "Operations", "Platform Settings", + "Ratings", + "Responsibilities", + "Site Setup", + "System Health", + "Tool Votes", ]); await expect(page.locator("[data-toolbox-votes-width-toggle]")).toHaveCount(0); await expect(page.locator("[data-toolbox-votes-width-status]")).toHaveCount(0); @@ -773,12 +905,7 @@ test("toolbox status kickers, filters, card order, and voting controls work from "Complete", "Deprecated", ]); - await expect(adminBuildVoteRow.locator("td").nth(5)).toHaveText("2"); - await expect(adminBuildVoteRow.locator("td").nth(6)).toHaveText("0"); - await expect(adminBuildVoteRow.locator("td").nth(7)).toHaveText("2"); - await expect(adminBuildVoteRow.locator("td").nth(8)).toHaveText("100%"); - await expect(adminBuildVoteRow.locator("td").nth(9)).toHaveText("0%"); - await expect(adminBuildVoteRow.locator("td").nth(10)).toHaveText("None"); + await expectAdminVoteRowState(adminBuildVoteRow, adminBuildVoteState); await adminBuildVoteRow.locator("td").nth(1).click(); await expect(adminBuildVoteRow).toHaveAttribute("aria-selected", "true"); await page.locator("[data-toolbox-votes-sort='toolName']").click(); @@ -815,11 +942,7 @@ test("toolbox status kickers, filters, card order, and voting controls work from await expect(adminBuildVoteRow.locator("td").nth(1)).toHaveText("1"); await expect(page.locator("[data-toolbox-votes-tool-id='game-hub'] td").nth(1)).toHaveText("2"); await expect(adminBuildVoteRow).toHaveAttribute("aria-selected", "true"); - await expect(page.locator("[data-toolbox-votes-tool-id='publish'] td").nth(5)).toHaveText("1"); - await expect(page.locator("[data-toolbox-votes-tool-id='publish'] td").nth(7)).toHaveText("1"); - await expect(page.locator("[data-toolbox-votes-tool-id='publish'] td").nth(8)).toHaveText("100%"); - await expect(page.locator("[data-toolbox-votes-tool-id='publish'] td").nth(9)).toHaveText("0%"); - await expect(page.locator("[data-toolbox-votes-tool-id='publish'] td").nth(10)).toHaveText("up"); + await expectAdminVoteRowState(page.locator("[data-toolbox-votes-tool-id='publish']"), publishVoteState); const colorsVoteRow = page.locator("[data-toolbox-votes-tool-id='colors']"); await colorsVoteRow.click(); @@ -842,14 +965,11 @@ test("toolbox status kickers, filters, card order, and voting controls work from await expect(colorsBuildPathRow.locator("[data-build-path-tool-link='Colors']")).toHaveAttribute("href", /toolbox\/colors\/index\.html$/); await expect(page.locator("[data-route='admin-tool-votes']")).toHaveCount(1); - const mockDbToolboxTables = await page.evaluate(async () => { - const response = await fetch("/api/local-db/snapshot"); - const payload = await response.json(); - return { - metadata: payload.data.tables.toolbox_tool_metadata, - votes: payload.data.tables.toolbox_votes, - }; - }); + const productDataSnapshot = await fetchApiData(server, "/api/product-data/snapshot"); + const mockDbToolboxTables = { + metadata: productDataSnapshot.tables.toolbox_tool_metadata, + votes: productDataSnapshot.tables.toolbox_votes, + }; expect(mockDbToolboxTables.votes).toEqual(expect.arrayContaining([ expect.objectContaining({ direction: "up", @@ -875,13 +995,15 @@ test("toolbox status kickers, filters, card order, and voting controls work from toolId: "colors", }), ])); + await restoreColorsToolMetadata(server); + await postApiData(server, "/api/toolbox/votes/order-list", { toolIds: originalToolOrder }); const toolboxSource = await page.evaluate(async () => { const response = await fetch("/toolbox/index.html"); return response.text(); }); expect(toolboxSource).not.toMatch(/]+src=)[^>]*>/i); - expect(toolboxSource).not.toMatch(/]/i); + expect(toolboxSource).not.toMatch(INLINE_STYLE_TAG_PATTERN); expect(toolboxSource).not.toContain("onclick="); expect(failedRequests).toEqual([]); @@ -890,6 +1012,8 @@ test("toolbox status kickers, filters, card order, and voting controls work from } finally { await workspaceV2CoverageReporter.stop(page); await server.close(); + restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); + restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); } }); @@ -1025,8 +1149,8 @@ test("toolbox grouped view renders Game Journey order with unique colors while B return response.text(); }); expect(toolboxSource).not.toMatch(/]+src=)[^>]*>/i); - expect(toolboxSource).not.toMatch(/]/i); - expect(toolboxSource).not.toMatch(/\sstyle=/i); + expect(toolboxSource).not.toMatch(INLINE_STYLE_TAG_PATTERN); + expect(toolboxSource).not.toMatch(INLINE_STYLE_ATTRIBUTE_PATTERN); expect(toolboxSource).not.toContain("onclick="); await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); @@ -1184,6 +1308,10 @@ test("Game Crew friendly route resolves while old Users route remains compatible test("toolbox Build Path status filters support multi-select registry-matched tool rows", async ({ page }) => { const server = await startRepoServer(); + const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; + const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; + process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; + process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; const failedRequests = []; const pageErrors = []; const consoleErrors = []; @@ -1236,6 +1364,8 @@ test("toolbox Build Path status filters support multi-select registry-matched to try { await workspaceV2CoverageReporter.start(page); + await setServerSession(server, MOCK_DB_KEYS.users.admin); + await restoreColorsToolMetadata(server); await setServerSession(server, MOCK_DB_KEYS.users.user1); const registryById = await toolMetadataById(server); await page.goto(`${server.baseUrl}/toolbox/index.html`, { waitUntil: "networkidle" }); @@ -1248,9 +1378,9 @@ test("toolbox Build Path status filters support multi-select registry-matched to await expect(page.locator("[data-tools-sort='grouped']")).not.toHaveClass(/primary/); await expect(page.locator("[data-toolbox-status-filter]")).toHaveText([ - "Planned (28)", + "Planned (27)", "Wireframe (4)", - "Beta (6)", + "Beta (8)", "Complete (3)", "Deprecated (1)", ]); @@ -1261,32 +1391,32 @@ test("toolbox Build Path status filters support multi-select registry-matched to await page.locator("[data-toolbox-status-filter='planned']").click(); await expectActiveFilters(["planned", "complete"]); - await expectBuildPathChannels(["planned", "complete"], 31); + await expectBuildPathChannels(["planned", "complete"], 30); await expect(page.locator("[data-build-path-tool='AI Command Center']")).toBeVisible(); await expectBuildPathOrder("AI Command Center", registryById.get("ai-assistant").order); await expectBuildPathOrder("Colors", registryById.get("colors").order); await page.locator("[data-toolbox-status-filter='complete']").click(); await expectActiveFilters(["planned"]); - await expectBuildPathChannels(["planned"], 28); + await expectBuildPathChannels(["planned"], 27); await expect(page.locator("[data-build-path-tool='Colors']")).toHaveCount(0); await expect(page.locator("[data-build-path-tool='AI Command Center']")).toBeVisible(); await page.locator("[data-toolbox-status-filter='wireframe']").click(); await expectActiveFilters(["planned", "wireframe"]); - await expectBuildPathChannels(["planned", "wireframe"], 32); + await expectBuildPathChannels(["planned", "wireframe"], 31); await expect(page.locator("[data-build-path-tool='Saved Data']")).toBeVisible(); await expect(page.locator("[data-build-path-tool='Build Game']")).toHaveCount(0); await page.locator("[data-toolbox-status-filter='deprecated']").click(); await expectActiveFilters(["planned", "wireframe", "deprecated"]); - await expectBuildPathChannels(["planned", "wireframe", "deprecated"], 33); + await expectBuildPathChannels(["planned", "wireframe", "deprecated"], 32); await expect(page.locator("[data-build-path-tool='Build Game']")).toBeVisible(); await expectBuildPathOrder("Build Game", registryById.get("build-game").order); await page.locator("[data-toolbox-status-filter='beta']").click(); await expectActiveFilters(["planned", "wireframe", "beta", "deprecated"]); - await expectBuildPathChannels(["planned", "wireframe", "beta", "deprecated"], 39); + await expectBuildPathChannels(["planned", "wireframe", "beta", "deprecated"], 40); expect(failedRequests).toEqual([]); expect(pageErrors).toEqual([]); @@ -1294,11 +1424,17 @@ test("toolbox Build Path status filters support multi-select registry-matched to } finally { await workspaceV2CoverageReporter.stop(page); await server.close(); + restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); + restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); } }); test("Colors Picker Preview header sort buttons reorder the grid", async ({ page }) => { const server = await startRepoServer(); + const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; + const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; + process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; + process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; const failedRequests = []; const pageErrors = []; const consoleErrors = []; @@ -1350,7 +1486,7 @@ test("Colors Picker Preview header sort buttons reorder the grid", async ({ page if (child.hasAttribute("data-palette-preview-controls")) return "preview-controls"; if (child.querySelector("[data-palette-generator-preview-status]")) return "preview-status"; return child.textContent.trim(); - }) + }).filter(Boolean) )); expect(summaryOrder).toEqual(["Picker Preview", "preview-controls", "preview-status"]); @@ -1385,11 +1521,17 @@ test("Colors Picker Preview header sort buttons reorder the grid", async ({ page } finally { await workspaceV2CoverageReporter.stop(page); await server.close(); + restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); + restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); } }); test("wireframe-only pages expose left center right accordion controls without runtime wiring", async ({ page }) => { const server = await startRepoServer(); + const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; + const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; + process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; + process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; const failedRequests = []; const pageErrors = []; const consoleErrors = []; @@ -1438,7 +1580,7 @@ test("wireframe-only pages expose left center right accordion controls without r return response.text(); }); expect(source).not.toMatch(/]+src=)[^>]*>/i); - expect(source).not.toMatch(/]/i); + expect(source).not.toMatch(INLINE_STYLE_TAG_PATTERN); expect(source).not.toMatch(/\son(?:click|change|input|submit|keydown|keyup|load)=/i); } @@ -1448,28 +1590,17 @@ test("wireframe-only pages expose left center right accordion controls without r } finally { await workspaceV2CoverageReporter.stop(page); await server.close(); + restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); + restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); } }); -test("local dev port guard redirects human localhost pages to port 5501", async ({ page }) => { +test("local dev pages remain on the repo test server", async ({ page }) => { const server = await startRepoServer(); try { await workspaceV2CoverageReporter.start(page); - await page.addInitScript(() => { - Object.defineProperty(Navigator.prototype, "webdriver", { - configurable: true, - get: () => false, - }); - }); - await page.route("http://127.0.0.1:5501/**", async (route) => { - await route.fulfill({ - body: "
Port guard target
", - contentType: "text/html", - status: 200, - }); - }); await page.goto(`${server.baseUrl}/toolbox/index.html`, { waitUntil: "domcontentloaded" }); - await expect(page).toHaveURL(/http:\/\/127\.0\.0\.1:5501\/toolbox\/index\.html$/); + expect(new URL(page.url()).origin).toBe(server.baseUrl); } finally { await workspaceV2CoverageReporter.stop(page); await server.close(); diff --git a/tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs b/tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs index e96e17752..57c52fedd 100644 --- a/tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs +++ b/tests/playwright/tools/ToolboxSelectedGameStatusBar.spec.mjs @@ -230,6 +230,11 @@ test("shared toolbox status bar shows selected Game Hub game above the footer", try { const statusBar = page.locator("[data-toolbox-status-bar]"); await expect(statusBar).toBeVisible(); + const displayMode = page.locator("#toolDisplayMode"); + await expect(displayMode.locator("summary [data-theme-icon='fullscreen']")).toHaveAttribute("data-theme-icon-file", "gfs-fullscreen.svg"); + await expect(displayMode.locator("[data-tool-nav-previous] [data-theme-icon='chevron-left']")).toHaveAttribute("data-theme-icon-file", "gfs-chevron-left.svg"); + await expect(displayMode.locator("[data-tool-nav-next] [data-theme-icon='chevron-right']")).toHaveAttribute("data-theme-icon-file", "gfs-chevron-right.svg"); + await expect(page.locator(".horizontal-accordion-toggle").first().locator("[data-theme-icon]")).toHaveAttribute("data-theme-icon-file", /gfs-chevron-(left|right)\.svg/); await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0); await expect(statusBar).not.toContainText("Environment"); await expectRemovedStatusBarLabelsHidden(statusBar); @@ -326,6 +331,7 @@ test("shared toolbox status bar anchors to the bottom in tool display mode", asy await expect(page.locator("[data-toolbox-status-bar]")).toBeVisible(); await page.locator("#toolDisplayMode summary").click(); await expect(page.locator("body")).toHaveClass(/tool-focus-mode/); + await expect(page.locator("#toolDisplayMode summary [data-theme-icon='exit-fullscreen']")).toHaveAttribute("data-theme-icon-file", "gfs-exit-fullscreen.svg"); const snapshot = await statusBarSnapshot(page); expect(snapshot.position).toBe("fixed");