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.
-
+
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(); }}>
-