From 600adb2b40ac666a0a2edd5f858385e17541346f Mon Sep 17 00:00:00 2001 From: BetaFury Date: Mon, 14 Sep 2026 09:58:45 -0400 Subject: [PATCH] fix: harden runtime offers and catalog editor --- .github/workflows/release.yml | 2 +- README-USER.txt | 2 +- README.md | 2 +- docs/DISTRIBUTION.md | 18 +- docs/showcase/utm-demo-icon-outputs.json | 18 + docs/showcase/utm-demo-icon-prompts.md | 47 +++ package-lock.json | 4 +- package.json | 3 +- scripts/verify-electron-ui.cjs | 415 +++++++++++++++++++++ src/components/BundleManager.tsx | 18 +- src/components/EntitlementList.tsx | 11 +- src/components/EntitlementModal.tsx | 18 +- src/components/OfferDisplayManager.tsx | 18 +- src/components/OfferRestrictionsEditor.tsx | 6 +- src/components/SetupPanel.tsx | 6 +- src/index.css | 15 + src/services/dynamicOffers.ts | 2 +- src/services/integrationContract.ts | 23 +- src/services/verseGenerator.ts | 50 +-- tests/agent-skill.test.ts | 2 +- tests/distribution.test.ts | 4 +- tests/dynamic-offers.test.ts | 40 +- tests/integration-contract.test.ts | 13 +- version.json | 2 +- 24 files changed, 650 insertions(+), 89 deletions(-) create mode 100644 docs/showcase/utm-demo-icon-outputs.json create mode 100644 docs/showcase/utm-demo-icon-prompts.md create mode 100644 scripts/verify-electron-ui.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 287dd05..34ed185 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: "release/UEFN-Transaction-Manager-Installer.exe", "release/UEFN-Transaction-Manager-Portable.zip" ) - $releaseFixLine = if ($version -eq "4.3.2") { "- Fixed portable update extraction so future portable releases can be verified and installed in place correctly.`n" } elseif ($version -eq "4.3.1") { "- Fixed an update-notification issue that could leave older builds showing `"Checking for updates...`" after a newer version was found.`n" } else { "" } + $releaseFixLine = if ($version -eq "4.3.3") { "- Fixed runtime price and quantity Verse generation plus catalog UI regressions in restrictions, modal overflow, and native select focus styling.`n" } elseif ($version -eq "4.3.2") { "- Fixed portable update extraction so future portable releases can be verified and installed in place correctly.`n" } elseif ($version -eq "4.3.1") { "- Fixed an update-notification issue that could leave older builds showing `"Checking for updates...`" after a newer version was found.`n" } else { "" } $compatibilityNote = if ($version -eq "4.3.2") { "Portable copies older than 4.3.2 may require a one-time manual download because those builds contain an older portable updater implementation." } else { "Existing portable copies from before 4.2.0 may need to download the new Portable ZIP manually once because those older binaries predate the in-place portable updater. Portable copies updated to 4.2.0 and later remain portable." } $notes = @" # UEFN Transaction Manager $version diff --git a/README-USER.txt b/README-USER.txt index 2c52746..c0a44af 100644 --- a/README-USER.txt +++ b/README-USER.txt @@ -1,4 +1,4 @@ -UEFN TRANSACTION MANAGER 4.3.2 +UEFN TRANSACTION MANAGER 4.3.3 GET STARTED diff --git a/README.md b/README.md index 9348302..4977311 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

UEFN Transaction Manager

A visual tool for building and managing Fortnite in-island transactions in UEFN.

- Version 4.3.2 + Version 4.3.3 Windows UEFN ADEPT Discord diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index 90ab354..b1f1dab 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -1,12 +1,12 @@ # UEFN Transaction Manager distribution and update infrastructure -Transaction Manager 4.3.2 separates human downloads from machine updates and keeps portable upgrades in-place. +Transaction Manager 4.3.3 separates human downloads from machine updates and keeps portable upgrades in-place. The cross-project Cloudflare inventory is maintained in the private [ADEPT-Interactive/infrastructure](https://github.com/ADEPT-Interactive/infrastructure) repository. This public document remains limited to the UTM update contract and intentionally contains no account credentials or secret values. ## Current supported release -`v4.3.2` is the sole supported retained public release. The release tag and Git history remain intact; this describes the current public distribution state only. +`v4.3.3` is the sole supported retained public release. The release tag and Git history remain intact; this describes the current public distribution state only. ## Human downloads @@ -32,14 +32,14 @@ A reserved future beta path is `uem/beta/`. The preferred R2 bucket is `adept-so ```text uem/stable/latest.yml -uem/stable/UEFN-Transaction-Manager-Setup-4.3.2.exe -uem/stable/UEFN-Transaction-Manager-Setup-4.3.2.exe.blockmap -uem/stable/manifests/4.3.2.yml +uem/stable/UEFN-Transaction-Manager-Setup-4.3.3.exe +uem/stable/UEFN-Transaction-Manager-Setup-4.3.3.exe.blockmap +uem/stable/manifests/4.3.3.yml uem/stable/portable-latest.json -uem/stable/UEFN-Transaction-Manager-4.3.2-Portable.zip +uem/stable/UEFN-Transaction-Manager-4.3.3-Portable.zip ``` -The stable R2 origin currently contains only the active 4.3.2 payload and feed objects listed above. `latest.yml` and `portable-latest.json` are the two mutable pointers; the referenced 4.3.2 artifacts are immutable for this release. Git history retains earlier source and release records, but older public artifacts are not supported by this document. +The stable R2 origin currently contains only the active 4.3.3 payload and feed objects listed above. `latest.yml` and `portable-latest.json` are the two mutable pointers; the referenced 4.3.3 artifacts are immutable for this release. Git history retains earlier source and release records, but older public artifacts are not supported by this document. ### Build and installer contract @@ -84,7 +84,7 @@ The helper uses `region=auto` and the account R2 endpoint. Values are read from ## Recovery and cutoff -Before promotion, fix staged objects and rerun verification. If a bad mutable manifest is promoted, restore a previously verified manifest only after confirming that its referenced immutable artifacts remain available. Recovery must leave the public stable origin on the supported 4.3.2 payload until a separately authorized release is ready. +Before promotion, fix staged objects and rerun verification. If a bad mutable manifest is promoted, restore a previously verified manifest only after confirming that its referenced immutable artifacts remain available. Recovery must leave the public stable origin on the supported 4.3.3 payload until a separately authorized release is ready. The compatibility namespace and updater path continue to support existing installs, including older 4.0.1 clients; this does not retain older public release artifacts. New versions use the ADEPT endpoint and retain the established `uem/stable/` path. @@ -99,7 +99,7 @@ The compatibility namespace and updater path continue to support existing instal 7. Review hashes, unsigned SmartScreen wording, and release notes. 8. Publish the release only after review; the separate workflow promotes `latest.yml` last. -The 4.3.2 installer remains unsigned in local builds unless a secure Authenticode certificate is supplied through the release environment. Signing is an owner-controlled launch gate; MCP integration and Agent Skill work remain part of the 4.3 release line. +The 4.3.3 installer remains unsigned in local builds unless a secure Authenticode certificate is supplied through the release environment. Signing is an owner-controlled launch gate; MCP integration and Agent Skill work remain part of the 4.3 release line. ## Rename compatibility boundary diff --git a/docs/showcase/utm-demo-icon-outputs.json b/docs/showcase/utm-demo-icon-outputs.json new file mode 100644 index 0000000..103fc5f --- /dev/null +++ b/docs/showcase/utm-demo-icon-outputs.json @@ -0,0 +1,18 @@ +{ + "style": "flat-cartoon-vector-sticker-v2", + "generator": "GPT ImageGen 2", + "sourceDirectory": "/01a09f98-92cb-7512-a87f-b6c04acbb440", + "destinationDirectory": "/Resources/GeneratedIcons", + "icons": [ + { "name": "Holo Trail", "source": "exec-c01410cb-47b9-435f-82d6-5c2632a6e850.png", "destination": "HoloTrail.png" }, + { "name": "Access Pass", "source": "exec-a7b47313-4171-4c09-9777-39aea3265117.png", "destination": "AccessPass.png" }, + { "name": "Builder Kit", "source": "exec-1b695218-4909-48ec-b35d-53338b62dbc2.png", "destination": "BuilderKit.png" }, + { "name": "Firework Charge", "source": "exec-56089d61-1c48-406e-ab99-1b1655b74ec7.png", "destination": "FireworkCharge.png" }, + { "name": "Ember Coins", "source": "exec-c7346879-b4e6-49aa-8ec2-dc46c91794e9.png", "destination": "EmberCoins.png" }, + { "name": "Vault Ticket", "source": "exec-93a643da-d372-4ad3-b856-99bd3060f7db.png", "destination": "VaultTicket.png" }, + { "name": "Mystery Cache", "source": "exec-47808796-4f6c-4838-b721-ea5a4e2cbcac.png", "destination": "MysteryCache.png" }, + { "name": "Starter Bundle", "source": "exec-0697a1d6-b926-4662-8e01-bdb6d0927445.png", "destination": "StarterBundle.png" }, + { "name": "Coin Restock", "source": "exec-496fc2fb-d722-4faf-8d1f-7f786f287a89.png", "destination": "CoinRestock.png" }, + { "name": "Event Loadout", "source": "exec-d3f05093-4160-427f-9fcd-2a9689d911e1.png", "destination": "EventLoadout.png" } + ] +} diff --git a/docs/showcase/utm-demo-icon-prompts.md b/docs/showcase/utm-demo-icon-prompts.md new file mode 100644 index 0000000..303baf3 --- /dev/null +++ b/docs/showcase/utm-demo-icon-prompts.md @@ -0,0 +1,47 @@ +# UTM_Demo icon generation prompts + +Generated with the built-in GPT ImageGen 2-backed image generation workflow for the UTM_Demo launch/demo fixture. + +## Shared visual system + +Use case: stylized-concept +Asset type: game UI icon +Style/medium: clean flat cartoon vector-like game UI icon, bold simplified geometry, thick dark navy outline, solid color fills, at most one subtle flat shadow shape, crisp edges, friendly premium game UI finish +Composition/framing: one centered object, front-facing or slight three-quarter view, bold silhouette, generous padding, square canvas, no cropping +Lighting/mood: even graphic lighting, no dramatic reflections +Color palette: cyan #28D7E5, electric blue #2D7DFF, violet #7C4DFF, magenta #E14BFF, warm orange #FF8A2A, gold #FFC857, deep navy #172447 +Materials/textures: simple flat enamel/metal shapes, minimal detail +Constraints: original abstract artwork only; no text; no letters; no numbers; no logos; no trademarks; no characters; no Fortnite or third-party imagery; genuinely transparent background; square PNG; crisp at small UI sizes; no watermark +Avoid: glossy 3D render, photorealism, realistic lighting, specular highlights, bevel-heavy rendering, gradients, background plane, white background, scenery, thin lines, tiny details, cropped edges + +## Individual prompts + +### Holo Trail +Primary request: a cyan and violet holographic comet trail, built from a compact round comet core and two clean ribbon-like energy streaks curling behind it, plus two or three simple diamond spark shapes. + +### Access Pass +Primary request: a purple premium access card with a cyan border, rounded corners, a simple geometric portal symbol in the center, and one small navy shadow shape. + +### Builder Kit +Primary request: an orange toolbox/build kit with a simple cyan handle, two chunky clasps, and one minimal cyan construction symbol made from basic geometric shapes. + +### Firework Charge +Primary request: an orange firework rocket-burst charge with a simple purple starburst behind it and a few cyan teardrop sparks, all in bold flat shapes. + +### Ember Coins +Primary request: three chunky orange-gold coins stacked diagonally, with simple cyan edge accents and one small ember shape between the coins. + +### Vault Ticket +Primary request: a blue and purple premium ticket with a clean die-cut edge, one simple navy keyhole cutout, and a cyan perforation line. + +### Mystery Cache +Primary request: a magenta and purple mystery cache capsule-crate with a rounded shape, a cyan energy seam, and two simple geometric spark shapes suggesting an unknown reward. + +### Starter Bundle +Primary request: a compact gift crate/starter pack box with cyan and purple panels, a warm orange ribbon, and a simple central cyan energy seal. + +### Coin Restock +Primary request: three orange-gold coins stacked together with a bold cyan circular refill arrow wrapping around the stack, using simple flat shapes and no text. + +### Event Loadout +Primary request: a cyan and purple event loadout crate with a warm orange latch and two bold diagonal cyan energy streaks suggesting a live event drop. diff --git a/package-lock.json b/package-lock.json index 480e2d1..4419cff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "uefn-entitlement-manager", - "version": "4.3.2", + "version": "4.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "uefn-entitlement-manager", - "version": "4.3.2", + "version": "4.3.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 4c22732..0fbc892 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uefn-entitlement-manager", - "version": "4.3.2", + "version": "4.3.3", "private": true, "description": "Project-scoped In-Island Transactions and Verse transaction manager for UEFN creators", "main": "dist-electron/main.cjs", @@ -23,6 +23,7 @@ "test:python": "python -m unittest discover -s tests -p \"test_*.py\"", "test:updater": "tsx --test tests/updater.test.ts", "test:portable-updater": "tsx --test tests/portable-update-helper.test.ts", + "test:ui-renderer": "electron scripts/verify-electron-ui.cjs", "test:desktop": "npm run install:electron-runtime && npm run build:desktop && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-electron-lifecycle.ps1 -Hidden -CatalogState uninitialized -TimeoutSeconds 60 && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-electron-lifecycle.ps1 -Hidden -CatalogState initialized -TimeoutSeconds 60 && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-electron-lifecycle.ps1 -Hidden -FailureRollback -TimeoutSeconds 60", "test:version": "node scripts/verify-version.mjs", "test:distribution": "tsx --test tests/distribution.test.ts", diff --git a/scripts/verify-electron-ui.cjs b/scripts/verify-electron-ui.cjs new file mode 100644 index 0000000..7efe09b --- /dev/null +++ b/scripts/verify-electron-ui.cjs @@ -0,0 +1,415 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { spawn } = require('node:child_process'); +const { app, BrowserWindow } = require('electron'); + +const repositoryRoot = path.resolve(__dirname, '..'); +const sourceProjectRoot = path.join(os.homedir(), 'Documents', 'UEFN Projects', 'UTM_Demo'); +const runtimeProjectRoot = path.join(os.tmpdir(), `utm-ui-renderer-${process.pid}`); +const sessionToken = crypto.randomBytes(48).toString('base64url'); +const editorToken = crypto.randomBytes(48).toString('base64url'); + +let bridgeProcess; +let bridgePort; +let windowRef; + +function wait(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +function reservePort() { + return new Promise((resolve, reject) => { + const listener = http.createServer(); + listener.once('error', reject); + listener.listen(0, '127.0.0.1', () => { + const address = listener.address(); + if (!address || typeof address === 'string') { + listener.close(); + reject(new Error('Could not reserve a loopback port for the renderer test.')); + return; + } + listener.close(error => error ? reject(error) : resolve(address.port)); + }); + }); +} + +function request(pathname, method = 'GET', body) { + return new Promise((resolve, reject) => { + const requestRef = http.request({ + host: '127.0.0.1', + port: bridgePort, + path: pathname, + method, + headers: { + 'X-UEM-Token': sessionToken, + ...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}), + }, + }, response => { + const chunks = []; + response.on('data', chunk => chunks.push(Buffer.from(chunk))); + response.on('end', () => resolve({ status: response.statusCode ?? 0, text: Buffer.concat(chunks).toString('utf8') })); + }); + requestRef.once('error', reject); + requestRef.end(body); + }); +} + +async function waitForBridge() { + for (let attempt = 0; attempt < 80; attempt += 1) { + if (bridgeProcess.exitCode !== null) throw new Error('The renderer test bridge exited before becoming healthy.'); + try { + const response = await request('/api/health'); + if (response.status === 200 && response.text.includes('UEFN Entitlement Manager Bridge')) return; + } catch { + // The bridge may still be binding its loopback listeners. + } + await wait(100); + } + throw new Error('The renderer test bridge did not become healthy within 8 seconds.'); +} + +function evaluate(source) { + return windowRef.webContents.executeJavaScript(`(${source})()`, true); +} + +async function waitForExpression(source, description, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const result = await evaluate(source); + if (result) return result; + } catch { + // React may still be loading the dashboard. + } + await wait(100); + } + let rendererSnapshot = ''; + try { rendererSnapshot = String(await evaluate("() => document.body?.innerText?.slice(0, 1200) ?? ''")); } catch { /* renderer may have exited */ } + throw new Error(`Timed out waiting for ${description}. Renderer text: ${rendererSnapshot}`); +} + +async function click(source, description) { + await waitForExpression(`() => { + const target = (${source})(); + if (!target) return false; + target.click(); + return true; + }`, description); +} + +function buttonByText(text, exact = false) { + return `() => [...document.querySelectorAll('button,[role="button"]')].find(element => { + const value = element.innerText?.trim() ?? element.textContent?.trim() ?? ''; + return ${exact ? `value === ${JSON.stringify(text)}` : `value.includes(${JSON.stringify(text)})`}; + })`; +} + +async function discardDialogChanges() { + await click(buttonByText('Discard changes', true), 'the unsaved-change discard action'); + await waitForExpression('() => !document.querySelector(\'[role="dialog"][aria-labelledby="entitlement-dialog-title"], [role="dialog"][aria-labelledby="bundle-dialog-title"], [role="dialog"][aria-labelledby="offer-display-dialog-title"]\')', 'the editor dialog to close'); +} + +async function selectCountry(editorIndex, code) { + const triggerSource = `() => [...document.querySelectorAll('button[aria-expanded]')].filter(element => (element.innerText ?? '').includes('Choose countries'))[${editorIndex}]`; + await waitForExpression(`() => { + const trigger = (${triggerSource})(); + if (!trigger) return false; + if (trigger.getAttribute('aria-expanded') !== 'true') trigger.click(); + return trigger.getAttribute('aria-expanded') === 'true'; + }`, `${code} country picker ${editorIndex + 1}`); + await click(`() => [...document.querySelectorAll('div.max-h-56 button')].find(element => (element.innerText ?? '').trim().endsWith(${JSON.stringify(code)}))`, `${code} country option`); +} + +async function selectedFlagReport() { + return evaluate(`async () => { + const response = await fetch('/flag-sprite.webp', { cache: 'no-store' }); + const bitmap = await createImageBitmap(await response.blob()); + return [...document.querySelectorAll('button[aria-label^="Remove "]')].map(button => { + const flag = button.parentElement?.querySelector('[style*="flag-sprite.webp"]'); + if (!flag) return { code: button.parentElement?.innerText ?? '', missing: true }; + const style = getComputedStyle(flag); + const rect = flag.getBoundingClientRect(); + const position = style.backgroundPosition.split(' ').map(value => Number.parseFloat(value)); + const size = style.backgroundSize.split(' ').map(value => Number.parseFloat(value)); + const canvas = document.createElement('canvas'); + canvas.width = Math.ceil(rect.width); + canvas.height = Math.ceil(rect.height); + const context = canvas.getContext('2d'); + let paintedPixels = 0; + if (context && position.length === 2 && size.length === 2) { + context.drawImage(bitmap, position[0], position[1], size[0], size[1]); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + for (let index = 3; index < pixels.length; index += 4) if (pixels[index] > 10) paintedPixels += 1; + } + return { + code: button.parentElement?.innerText ?? '', + missing: false, + width: rect.width, + height: rect.height, + backgroundImage: style.backgroundImage, + backgroundPosition: style.backgroundPosition, + backgroundSize: style.backgroundSize, + paintedPixels, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + }; + }); + }`); +} + +async function assertDialogLayout(dialogSelector, bodySelector, description) { + const report = await evaluate(`() => { + const dialog = document.querySelector(${JSON.stringify(dialogSelector)}); + const body = dialog?.querySelector(${JSON.stringify(bodySelector)}); + if (!dialog || !body) return null; + const shellStyle = getComputedStyle(dialog); + const bodyStyle = getComputedStyle(body); + const shellRect = dialog.getBoundingClientRect(); + const bodyRect = body.getBoundingClientRect(); + return { + shellOverflow: shellStyle.overflow, + shellMinHeight: shellStyle.minHeight, + bodyOverflowY: bodyStyle.overflowY, + bodyMinHeight: bodyStyle.minHeight, + shellTop: shellRect.top, + shellBottom: shellRect.bottom, + shellHeight: shellRect.height, + bodyTop: bodyRect.top, + bodyBottom: bodyRect.bottom, + bodyHeight: bodyRect.height, + bodyBottom: bodyRect.bottom, + viewportHeight: window.innerHeight, + bodyScrolls: body.scrollHeight >= body.clientHeight, + }; + }`); + assert.ok(report, `${description} should render its shell and scroll body`); + assert.equal(report.shellOverflow, 'hidden', `${description} shell must clip its rounded boundary`); + assert.equal(report.bodyOverflowY, 'auto', `${description} body must own vertical scrolling`); + assert.equal(report.bodyMinHeight, '0px', `${description} body must be allowed to shrink in a flex shell`); + assert.ok(report.shellHeight <= report.viewportHeight - 1, `${description} shell must remain inside the viewport`); + assert.ok(report.bodyTop >= report.shellTop - 1 && report.bodyBottom <= report.shellBottom + 1, `${description} body must render inside its shell`); +} + +async function assertSelectFocus() { + // Chromium does not activate the :focus pseudo-class for a fully hidden + // native window. Briefly showing the already-rendered window lets this + // check observe the same focus cascade a user sees, without capturing or + // writing any visual artifact. + windowRef.show(); + windowRef.focus(); + const report = await evaluate(`() => [...document.querySelectorAll('select.utm-native-select')].map(element => { + element.focus(); + const style = getComputedStyle(element); + return { label: element.getAttribute('aria-label'), active: document.activeElement === element, matchesFocus: element.matches(':focus'), matchesFocusVisible: element.matches(':focus-visible'), outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, outlineColor: style.outlineColor, boxShadow: style.boxShadow }; + })`); + windowRef.hide(); + assert.ok(report.length >= 1, 'at least one native select should use the shared focus class'); + for (const item of report) { + assert.equal(item.outlineStyle, 'solid', `${item.label} should have a visible focus outline`); + assert.equal(item.outlineWidth, '2px', `${item.label} should use the shared 2px focus outline`); + assert.equal(item.boxShadow, 'none', `${item.label} should not retain the native yellow/orange focus shadow`); + } +} + +async function assertViewportMatrix(dialogSelector, bodySelector, description) { + const cases = [ + { width: 1920, height: 1080, zoom: 1 }, + { width: 1366, height: 768, zoom: 1 }, + { width: 1180, height: 760, zoom: 1 }, + { width: 1536, height: 864, zoom: 1.25 }, + { width: 1280, height: 720, zoom: 1.5 }, + ]; + for (const viewport of cases) { + windowRef.setSize(viewport.width, viewport.height); + windowRef.webContents.setZoomFactor(viewport.zoom); + await wait(80); + await assertDialogLayout(dialogSelector, bodySelector, `${description} at ${viewport.width}x${viewport.height} ${viewport.zoom * 100}%`); + } + windowRef.webContents.setZoomFactor(1); + windowRef.setSize(1440, 900); + await wait(80); +} + +async function runRendererAssertions() { + const sprite = await evaluate(`async () => { + const response = await fetch('/flag-sprite.webp', { cache: 'no-store' }); + return { ok: response.ok, contentType: response.headers.get('content-type') }; + }`); + assert.equal(sprite.ok, true, 'the flag atlas must be served to the renderer'); + assert.match(sprite.contentType ?? '', /image\/webp/i); + + const bodyText = await waitForExpression('() => document.body?.innerText?.includes(\'Bundle offers\') && document.body?.innerText?.includes(\'Storefront membership\')', 'the populated catalog dashboard'); + assert.equal(bodyText, true); + + await click('() => document.querySelector(\'[aria-label="Edit Holo Trail"]\')', 'the Holo Trail editor'); + await waitForExpression('() => document.querySelector(\'[role="dialog"][aria-labelledby="entitlement-dialog-title"]\')', 'the entitlement editor'); + assert.match(await evaluate('() => document.body.innerText'), /increments of 50/i); + assert.doesNotMatch(await evaluate('() => document.body.innerText'), /step 50/i); + await click('() => [...document.querySelectorAll(\'button\')].find(element => element.innerText?.trim() === \'Advanced\')', 'the entitlement advanced controls'); + await waitForExpression('() => [...document.querySelectorAll(\'button\')].filter(element => (element.innerText ?? \'\').includes(\'Choose countries\')).length === 1', 'the normal restriction editor'); + await selectCountry(0, 'CA'); + await selectCountry(0, 'US'); + await click('() => [...document.querySelectorAll(\'button\')].find(element => element.innerText?.trim() === \'Add variant\')', 'an alternate offer editor'); + await waitForExpression('() => [...document.querySelectorAll(\'button\')].filter(element => (element.innerText ?? \'\').includes(\'Choose countries\')).length === 2', 'the alternate compact restriction editor'); + await selectCountry(1, 'CA'); + await selectCountry(1, 'US'); + const offerFlags = await selectedFlagReport(); + assert.equal(offerFlags.length, 4, 'normal and alternate restrictions should retain both selected countries'); + for (const flag of offerFlags) { + assert.equal(flag.missing, false, `${flag.code} should render a flag element`); + assert.ok(flag.width >= 22.5 && flag.width <= 24.5, `${flag.code} flag width should stay close to 24 CSS pixels (got ${flag.width})`); + assert.ok(flag.height >= 16.5 && flag.height <= 18.5, `${flag.code} flag height should stay close to 18 CSS pixels (got ${flag.height})`); + assert.match(flag.backgroundImage, /flag-sprite\.webp/); + assert.ok(flag.paintedPixels > 0, `${flag.code} flag atlas crop should contain painted pixels`); + assert.notEqual(flag.visibility, 'hidden'); + assert.notEqual(flag.display, 'none'); + assert.notEqual(flag.opacity, '0'); + } + await assertSelectFocus(); + await assertDialogLayout('[role="dialog"][aria-labelledby="entitlement-dialog-title"]', '#offer-editor-panel', 'the entitlement editor'); + await assertViewportMatrix('[role="dialog"][aria-labelledby="entitlement-dialog-title"]', '#offer-editor-panel', 'the entitlement editor'); + await click('() => document.querySelector(\'[aria-label="Close offer editor"]\')', 'the entitlement editor close action'); + await discardDialogChanges(); + + await click('() => document.querySelector(\'[aria-label="Edit Starter Bundle"]\')', 'the Starter Bundle editor'); + await waitForExpression('() => document.querySelector(\'[role="dialog"][aria-labelledby="bundle-dialog-title"]\')', 'the bundle editor'); + await selectCountry(0, 'CA'); + await selectCountry(0, 'US'); + const bundleFlags = await selectedFlagReport(); + assert.equal(bundleFlags.length, 2, 'bundle restrictions should retain both selected countries'); + for (const flag of bundleFlags) { + assert.equal(flag.missing, false, `${flag.code} should render in the bundle restriction editor`); + assert.ok(flag.width >= 22.5 && flag.width <= 24.5); + assert.ok(flag.height >= 16.5 && flag.height <= 18.5); + assert.match(flag.backgroundImage, /flag-sprite\.webp/); + assert.ok(flag.paintedPixels > 0, `${flag.code} flag atlas crop should contain painted pixels`); + } + await assertSelectFocus(); + await assertDialogLayout('[role="dialog"][aria-labelledby="bundle-dialog-title"]', 'form > div.min-h-0', 'the bundle editor'); + await click('() => document.querySelector(\'[aria-label="Close bundle editor"]\')', 'the bundle editor close action'); + await discardDialogChanges(); + + await click('() => document.querySelector(\'[aria-label="Edit All Offers membership"]\')', 'the All Offers editor'); + await waitForExpression('() => document.querySelector(\'[role="dialog"][aria-labelledby="offer-display-dialog-title"]\')', 'the All Offers editor'); + await assertDialogLayout('[role="dialog"][aria-labelledby="offer-display-dialog-title"]', 'form > div.min-h-0', 'the storefront editor'); + await click('() => document.querySelector(\'[aria-label="Close storefront editor"]\')', 'the storefront editor close action'); + await waitForExpression('() => !document.querySelector(\'[role="dialog"][aria-labelledby="offer-display-dialog-title"]\')', 'the storefront editor to close'); + + await click(buttonByText('Create Offer', true), 'the creation chooser'); + await waitForExpression('() => document.querySelector(\'[role="dialog"][aria-labelledby="creation-chooser-title"]\')', 'the creation chooser'); + const chooser = await evaluate(`() => { + const dialog = document.querySelector('[role="dialog"][aria-labelledby="creation-chooser-title"]'); + const body = dialog?.querySelector('.min-h-0.flex-1.overflow-y-auto'); + return body ? { shellOverflow: getComputedStyle(dialog).overflow, bodyOverflowY: getComputedStyle(body).overflowY, bodyMinHeight: getComputedStyle(body).minHeight } : null; + }`); + assert.ok(chooser); + assert.equal(chooser.shellOverflow, 'hidden'); + assert.equal(chooser.bodyOverflowY, 'auto'); + assert.equal(chooser.bodyMinHeight, '0px'); + await click('() => document.querySelector(\'[aria-label="Close creation chooser"]\')', 'the creation chooser close action'); + await waitForExpression('() => !document.querySelector(\'[role="dialog"][aria-labelledby="creation-chooser-title"]\')', 'the creation chooser to close'); + + await click('() => [...document.querySelectorAll(\'button\')].find(element => element.innerText?.trim() === \'Need Help?\')', 'the setup dialog'); + await waitForExpression('() => document.querySelector(\'[role="dialog"] h2\')?.innerText === \'Need Help?\'', 'the setup dialog'); + await assertDialogLayout('[role="dialog"]', '.min-h-0.flex-1.overflow-y-auto', 'the setup dialog'); + await click('() => document.querySelector(\'[aria-label="Close help"]\')', 'the setup dialog close action'); + + const results = await evaluate(`() => ({ + dialogs: document.querySelectorAll('[role="dialog"], [role="alertdialog"]').length, + catalogItems: [...document.querySelectorAll('article')].length, + nativeSelects: document.querySelectorAll('select.utm-native-select').length, + })`); + assert.equal(results.dialogs, 0, 'the renderer test must leave no modal open'); + assert.ok(results.catalogItems >= 10, 'the populated catalog should remain rendered after the checks'); + assert.equal(results.nativeSelects, 0, 'modal-only native selects should be removed when all dialogs close'); +} + +async function cleanup() { + try { + if (windowRef && !windowRef.isDestroyed()) windowRef.destroy(); + } catch { + // Cleanup continues even if Chromium has already exited. + } + if (bridgeProcess && bridgeProcess.exitCode === null) { + try { await request('/api/session/shutdown', 'POST', '{}'); } catch { /* the bridge may already be stopping */ } + await Promise.race([ + new Promise(resolve => bridgeProcess.once('exit', resolve)), + wait(3000), + ]); + if (bridgeProcess.exitCode === null) bridgeProcess.kill(); + } + fs.rmSync(runtimeProjectRoot, { recursive: true, force: true }); +} + +async function main() { + if (!fs.existsSync(path.join(repositoryRoot, 'dist', 'index.html'))) throw new Error('The renderer build is missing. Run npm run build first.'); + if (!fs.existsSync(path.join(sourceProjectRoot, 'UTM_Demo.uefnproject'))) throw new Error(`The UTM_Demo fixture was not found at ${sourceProjectRoot}.`); + fs.rmSync(runtimeProjectRoot, { recursive: true, force: true }); + fs.mkdirSync(path.join(runtimeProjectRoot, 'Content'), { recursive: true }); + fs.copyFileSync(path.join(sourceProjectRoot, 'UTM_Demo.uefnproject'), path.join(runtimeProjectRoot, 'UTM_Demo.uefnproject')); + fs.cpSync(path.join(sourceProjectRoot, 'Content'), path.join(runtimeProjectRoot, 'Content'), { recursive: true }); + + bridgePort = await reservePort(); + bridgeProcess = spawn(process.execPath, [path.join(repositoryRoot, 'dist', 'server.cjs')], { + cwd: repositoryRoot, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + PORT: String(bridgePort), + UEM_SESSION_TOKEN: sessionToken, + UEM_EDITOR_TOKEN: editorToken, + UEM_CONTENT_ROOT: path.join(runtimeProjectRoot, 'Content'), + UEM_ASSET_MOUNT: '/UTM_Demo', + UEM_PROJECT_FILE: path.join(runtimeProjectRoot, 'UTM_Demo.uefnproject'), + UEM_PROJECT_PYTHON_ENABLED: '1', + UEM_AUTO_CONNECTOR_INSTALLED: '0', + UEM_UEFN_PROCESS_ID: '0', + UEM_TEST_MODE: '1', + UEM_TEST_NO_GLOBAL_UEFN_PROBE: '1', + UEM_IDLE_TIMEOUT_MS: '600000', + }, + }); + bridgeProcess.stdout.on('data', () => undefined); + bridgeProcess.stderr.on('data', () => undefined); + await waitForBridge(); + + windowRef = new BrowserWindow({ + show: false, + width: 1440, + height: 900, + backgroundColor: '#080c14', + webPreferences: { contextIsolation: true, sandbox: true }, + }); + const contentRoot = path.join(runtimeProjectRoot, 'Content'); + const fragment = new URLSearchParams({ + token: sessionToken, + contentDir: contentRoot, + assetFolder: 'EntitlementIcons', + verseFile: 'managed_transactions.verse', + projectFile: path.join(runtimeProjectRoot, 'UTM_Demo.uefnproject'), + }); + await windowRef.loadURL(`http://127.0.0.1:${bridgePort}/#${fragment}`); + await waitForExpression('() => Boolean(document.getElementById(\'root\')?.firstElementChild)', 'the React dashboard root'); + await runRendererAssertions(); + console.log('Electron UI regression checks passed without capturing screenshots.'); +} + +app.commandLine.appendSwitch('disable-gpu'); +app.whenReady().then(async () => { + try { + await main(); + await cleanup(); + app.quit(); + } catch (error) { + console.error(error instanceof Error ? error.stack ?? error.message : String(error)); + await cleanup(); + app.exit(1); + } +}); diff --git a/src/components/BundleManager.tsx b/src/components/BundleManager.tsx index e89ad2b..b69037d 100644 --- a/src/components/BundleManager.tsx +++ b/src/components/BundleManager.tsx @@ -116,10 +116,11 @@ const BundleEditorModal: React.FC<{ return (

{ if (event.currentTarget === event.target) requestClose(); }}> -
-

{isExisting ? 'Edit bundle offer' : 'Create bundle offer'}

-
{ event.preventDefault(); commitForm(); }} className="space-y-4"> -
+
+

{isExisting ? 'Edit bundle offer' : 'Create bundle offer'}

+ { event.preventDefault(); commitForm(); }} className="flex min-h-0 flex-1 flex-col"> +
+