From df707bf8e1fd33ff5c3168725aa9025c4eb7feae Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 26 Sep 2026 17:50:36 -0700 Subject: [PATCH] performance changes (#1959) --- docs/performance.md | 219 ++++++++ docs/performance/2026-09-26-after.json | 527 ++++++++++++++++++ docs/performance/2026-09-26-before.json | 510 +++++++++++++++++ docs/website-login.md | 80 +++ .../apps/artcraft/app/src/pages/MainApp.tsx | 96 ++-- .../app/src/pages/Stores/TabState.spec.ts | 84 +++ .../artcraft/app/src/pages/Stores/TabState.ts | 39 +- frontend/package-lock.json | 46 ++ frontend/package.json | 5 +- .../tools/performance/benchmark-plugin.mjs | 54 ++ .../tools/performance/browser-fixture.mjs | 103 ++++ frontend/tools/performance/compare.mjs | 33 ++ frontend/tools/performance/run.mjs | 125 +++++ 13 files changed, 1851 insertions(+), 70 deletions(-) create mode 100644 docs/performance.md create mode 100644 docs/performance/2026-09-26-after.json create mode 100644 docs/performance/2026-09-26-before.json create mode 100644 docs/website-login.md create mode 100644 frontend/apps/artcraft/app/src/pages/Stores/TabState.spec.ts create mode 100644 frontend/tools/performance/benchmark-plugin.mjs create mode 100644 frontend/tools/performance/browser-fixture.mjs create mode 100644 frontend/tools/performance/compare.mjs create mode 100644 frontend/tools/performance/run.mjs diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000000..04162b2039 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,219 @@ +# Desktop frontend performance experiments + +Measured September 26, 2026 on an Apple M4 Pro, macOS arm64, Chrome +153.0.8010.53, Node 24.13.0. These are production frontend measurements in +headless Chrome with mocked Tauri services. They do **not** measure native +process launch, WKWebView/WebView2, Rust, disk startup, real account loading, +or generation latency. + +## Changes and results + +Seven samples per version; table values are medians. Each sample uses a fresh +browser context at 1440 × 1000 with device scale 1 and browser HTTP cache +disabled. The browser process stays alive between samples. A reload in the +same context is measured separately. There is no CPU/network throttling. +External requests, including analytics, fonts, and media, are blocked in both +versions. The login fixture is signed in; the bundled model overlay is used. + +| Measurement | Before | After | Reduction | +|-----------------------------|-----------|-----------|-----------| +| Startup to frame | 566.40 ms | 350.60 ms | 38.1% | +| Refresh to frame | 248.60 ms | 198.10 ms | 20.3% | +| Startup JavaScript | 9.05 MB | 6.47 MB | 28.6% | +| Startup long tasks (sum) | 336.00 ms | 184.00 ms | 45.2% | +| First drawing page to frame | 120.90 ms | 118.20 ms | 2.2% | +| Drawing to home: dispatch | 23.40 ms | 9.00 ms | 61.5% | +| Drawing to home: frame | 29.50 ms | 23.00 ms | 22.0% | +| Home to drawing: dispatch | 42.10 ms | 18.80 ms | 55.3% | +| Home to drawing: frame | 105.70 ms | 86.50 ms | 18.2% | + +The 2.2% first-open difference is small enough to treat as unchanged. These +local measurements are evidence of reduced frontend work, not predictions +of identical percentage gains on every user's machine. Raw samples, ranges, +environment, fixture sizes, browser errors, and build provenance are in +[before.json](performance/2026-09-26-before.json) and +[after.json](performance/2026-09-26-after.json). + +Three changes produced these results: + +1. `MainApp.tsx` imported every editor at launch. Fifteen pages now use + `React.lazy` behind a Suspense boundary below the persistent shell. Their + code loads when selected; the top bar stays available during loading. + The default Apps page remains eager. Drawing also remains eager: its + shared code is already part of the shell, and experimentally deferring + its small host adapter made first-open latency worse for little saving. + First-open latency for the other deferred editors is a remaining tradeoff + to measure, especially on slower disks/CPUs. All 15 deferred modules were + imported successfully in the browser, but this is not an end-to-end test + of every editor's features. +2. `TabState.ts` exported the drawing scene to JSON when leaving and imported + it when returning. The Zustand scene store already survives page unmount. + With four imported images, the old path performed four FileReader reads + on exit and four more on return, allocated base64 copies, rebuilt image + nodes, cleared the selection, and appended an undo-history entry. Tab + changes now keep the live store. The measured round trip performs **zero + image-file reads**, preserves the selected node, and keeps history length + at one instead of growing to two. Explicit file export/import remains + available. Tests cover image identity, model metadata, selection, redo, + gallery reset, rapid navigation, and portable file export. +3. The shell ran `getGPUTier()` at startup and stored its result in an unread + React state value. Removing it eliminates **one startup WebGL context + request** and its associated detection work. The real 3D renderer still + creates its context when used. This experiment reports combined timing + gains, not an isolated timing attribution for each individual change. + +## Repeat the experiment + +From the repository root: + +```sh +cd frontend +npm ci +npm run perf:desktop -- --output /tmp/artcraft-perf-current --smoke-imports +``` + +The default browser is an installed Google Chrome (`--channel chrome`). The +runner requires permission to launch a browser and bind a loopback server. +It never uses your regular browser profile, account, or native app data. +`@playwright/test` is pinned as a development dependency. Artifacts are written +outside the repository by default. Use a dedicated output directory: its +`dist` subdirectory is rebuilt by Vite. + +For this particular two-module change, reproduce the original implementation +without replacing local source files: + +```sh +npm run perf:desktop -- --output /tmp/artcraft-perf-before \ + --baseline-ref 780e4494e995163fed1d677b88c3cf03b3fca63c +npm run perf:desktop -- --output /tmp/artcraft-perf-after --smoke-imports +node tools/performance/compare.mjs \ + /tmp/artcraft-perf-before/results.json \ + /tmp/artcraft-perf-after/results.json +``` + +`--baseline-ref` overlays **only** `MainApp.tsx` and `pages/Stores/TabState.ts` +from that Git revision in the benchmark build. All other source files and +dependencies remain current. It reproduces the two production files changed +in this experiment; it is not a general historical-checkout benchmark. For +future changes, capture a normal baseline **before** editing, use a new output +directory for the changed version, and keep the runner, fixture, dependencies, +browser, and machine identical. Run sequentially while the machine is idle; +do not build or run other CPU-heavy tasks during samples. Repeat in reverse +order if results are close or noisy. + +Useful options: + +```sh +# Build in a restricted shell, then run where browser/server use is allowed. +npm run perf:desktop -- --output /tmp/artcraft-perf-current --build-only +npm run perf:desktop -- --output /tmp/artcraft-perf-current --skip-build + +# More samples; seven is the default. +npm run perf:desktop -- --output /tmp/artcraft-perf-current --runs 15 + +# Correctness checks for the navigation change. +npx vitest run --config apps/artcraft/vitest.config.ts +``` + +Do not use `--skip-build` after changing app code; it intentionally measures +the existing artifact. `build-info.json` records the revision, changed-file +names, baseline overlay, and build time. The benchmark plugin alone exposes +the stores needed to seed the scene. It is not part of the normal Vite/Tauri +build, and no mock IPC or benchmark global is added to the shipped app. + +The runner builds from the app's working directory, matching its normal +build command. This matters because Tailwind resolves its config and content +relative to that directory. It rejects a run without working Tailwind styles +or with uncaught browser errors. `drawing.png` provides a visual check after +the measured operations. The initial unstyled exploratory runs were discarded. + +## What the measurements mean + +The fixture creates four deterministic 1024 × 1024 PNG image nodes and 80 +strokes of 200 coordinates each, with one selected image. On the recorded +browser each PNG is 2,356,706 bytes. Fixture construction, image decoding, +and a one-second settling period are outside the tab-transition timings. +Each sample follows the same sequence: + +1. Navigate to the production app, wait for the Apps heading and for the + signed-in login overlay to disappear, then wait two animation frames. +2. Reload that page and repeat the same readiness checks. +3. Seed the scene while on Apps, open drawing, and wait for its canvas and + two animation frames. Allow a second for its background bake to settle. +4. Switch to Apps and then back to drawing, measuring each separately. +5. After a settling period, inspect node count, selection, history, and IPC + counters. Save a screenshot on the first run. Optionally evaluate all + deferred page modules after the final timed operation. + +`readyMs` is `performance.now()` after readiness checks and two animation +frames; its origin is navigation start. It includes browser loading and +automation readiness-observation overhead. `jsBytes` sums decoded resource +sizes of loaded JavaScript; it is uncompressed bytes, not all packaged files. +`longTaskMs` sums observed startup tasks longer than 50 ms, not total CPU time +or total blocking time. Long Tasks support is browser-dependent. + +`stateReadyMs` measures the awaited `setActiveTab()` action, including React +work synchronously flushed by its store subscribers. `paintMs` continues +until the destination heading/canvas exists and two animation frames have +passed. This is a **rendering-opportunity proxy**, not proof of GPU presentation +or of every asynchronous image decode completing. In particular, the old +return path could still be decoding images after the canvas first appeared. +Tab measurements also create `artcraft:tab:APPS` and `artcraft:tab:2D` User +Timing entries in the benchmark page. + +The runner calls the same store action used by navigation UI. It excludes +pointer-event dispatch, menu animations, and physical click-to-screen latency. +It does not automate paid generations, sign in to a real account, or measure +remote services. Save the raw JSON as well as the summary; a single minimum +time is not a reliable comparison. + +`dist/performance-bundle.json` ranks the largest retained modules per chunk +and records chunk imports. Module `renderedBytes` is Rollup's intermediate +module size; it is useful for attribution but should not be summed as final +minified/gzipped size. Use actual resource bytes for the startup total. + +## Remaining investigation + +- The shell still loads about 6.47 MB of JavaScript. Shared imports through + drawing, scene, gallery, and settings entry points need a deeper dependency + audit. Separate lightweight store/type imports from editor code, then use + the bundle report and the same startup experiment to verify what actually + leaves the initial graph. The report identifies Spark, Three.js core and + renderer, the inline FBX-to-GLB conversion worker, PostHog, and Mediabunny + among the largest retained modules. Their presence is measured; the runtime + benefit of deferring each individually has not yet been measured. +- Returning to the populated canvas still takes about 86 ms. The editor + remount, Konva drawing, image/transformer setup, and composite bake need a + timeline profile before choosing the next change. Keep scene restoration, + undo, selection, and background generation behavior in correctness checks. +- `TaskQueue.tsx` polls every five seconds and rebuilds task arrays even when + the popover is closed. The benchmark uses an empty queue, so it does not + establish the cost for a large real queue. A fixed, representative task + fixture plus idle CPU/render counts is the next useful experiment there. +- `useStageSnapshot` looks expensive in isolation, but its only call is + commented out in `PaintSurface.tsx`. It was excluded as a current bottleneck. + +Validation completed: normal production Vite build, all 10 app tests and 20 +login tests, all 15 deferred page-module imports, visual canvas inspection, +and a check that the normal build contains no benchmark globals or fixtures. +The broad TypeScript check remains blocked by existing workspace declaration/ +project-reference errors and Remix/Deno types. Comparing diagnostics with the +two original modules showed the same diagnostic messages (some import traces +and reporting locations changed); this is not a clean typecheck claim. + +## Validate in the native app + +Before claiming native launch or interaction gains, run matching release +Tauri builds on the same machine and WebView version. Keep account, window +size, network conditions, and canvas artwork fixed. Record the Web Inspector +timeline for launch/reload, opening a tool for the first time, drawing → Apps, +and Apps → drawing. Repeat at least seven times and retain the traces with +build IDs and medians. Separate native process launch from page reload; do +not mix dev/HMR traces with production measurements. + +For canvas tests, create a blank 1024-square canvas, import the same four +images, add the same strokes, select an image, and wait for background work +before switching. Check image content, selection, undo/redo, and opening a new +gallery image afterward. Record actual click-to-visible-frame latency in +the inspector as well as JavaScript tasks. The browser results above justify +the code changes but do not replace that native validation. diff --git a/docs/performance/2026-09-26-after.json b/docs/performance/2026-09-26-after.json new file mode 100644 index 0000000000..0c18691c97 --- /dev/null +++ b/docs/performance/2026-09-26-after.json @@ -0,0 +1,527 @@ +{ + "timestamp": "2026-09-27T00:32:54.524Z", + "build": { + "builtAt": "2026-09-27T00:30:35.760Z", + "revision": "780e4494e995163fed1d677b88c3cf03b3fca63c", + "baselineOverlay": null, + "changedFiles": [ + "frontend/apps/artcraft/app/src/pages/MainApp.tsx", + "frontend/apps/artcraft/app/src/pages/Stores/TabState.ts", + "frontend/package-lock.json", + "frontend/package.json" + ] + }, + "environment": { + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M4 Pro", + "browser": "153.0.8010.53", + "node": "v24.13.0", + "runs": 7, + "viewport": "1440x1000@1", + "ipc": "mocked", + "network": "local assets only, browser cache disabled" + }, + "summary": { + "startup": { + "readyMs": { + "median": 350.60000002384186, + "min": 348.7000000476837, + "max": 457.39999997615814 + }, + "jsBytes": { + "median": 6466606, + "min": 6466606, + "max": 6466606 + }, + "longTaskMs": { + "median": 184, + "min": 179, + "max": 284 + }, + "webglContexts": { + "median": 0, + "min": 0, + "max": 0 + } + }, + "refresh": { + "readyMs": { + "median": 198.10000002384186, + "min": 197.10000002384186, + "max": 198.80000007152557 + }, + "jsBytes": { + "median": 6466606, + "min": 6466606, + "max": 6466606 + }, + "longTaskMs": { + "median": 0, + "min": 0, + "max": 0 + }, + "webglContexts": { + "median": 0, + "min": 0, + "max": 0 + } + }, + "firstDraw": { + "stateReadyMs": { + "median": 38.300000071525574, + "min": 37.60000002384186, + "max": 40.299999952316284 + }, + "paintMs": { + "median": 118.19999992847443, + "min": 115.30000007152557, + "max": 120 + }, + "fileReads": { + "median": 0, + "min": 0, + "max": 0 + } + }, + "leaveDraw": { + "stateReadyMs": { + "median": 9, + "min": 7.5, + "max": 11 + }, + "paintMs": { + "median": 23, + "min": 19.600000023841858, + "max": 26.600000023841858 + }, + "fileReads": { + "median": 0, + "min": 0, + "max": 0 + } + }, + "returnDraw": { + "stateReadyMs": { + "median": 18.800000071525574, + "min": 15.799999952316284, + "max": 20.700000047683716 + }, + "paintMs": { + "median": 86.5, + "min": 78.79999995231628, + "max": 90.10000002384186 + }, + "fileReads": { + "median": 0, + "min": 0, + "max": 0 + } + } + }, + "samples": [ + { + "startup": { + "readyMs": 457.39999997615814, + "jsBytes": 6466606, + "longTaskMs": 284, + "webglContexts": 0 + }, + "refresh": { + "readyMs": 197.80000007152557, + "jsBytes": 6466606, + "longTaskMs": 0, + "webglContexts": 0 + }, + "firstDraw": { + "stateReadyMs": 38.800000071525574, + "paintMs": 119.5, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 7.600000023841858, + "paintMs": 20.399999976158142, + "fileReads": 0 + }, + "returnDraw": { + "stateReadyMs": 17.799999952316284, + "paintMs": 87.79999995231628, + "fileReads": 0 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 1, + "historyLength": 1, + "commands": { + "plugin:event|listen": 25, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 365.7999999523163, + "jsBytes": 6466606, + "longTaskMs": 185, + "webglContexts": 0 + }, + "refresh": { + "readyMs": 197.10000002384186, + "jsBytes": 6466606, + "longTaskMs": 0, + "webglContexts": 0 + }, + "firstDraw": { + "stateReadyMs": 37.60000002384186, + "paintMs": 116, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 7.5, + "paintMs": 26.600000023841858, + "fileReads": 0 + }, + "returnDraw": { + "stateReadyMs": 15.799999952316284, + "paintMs": 78.79999995231628, + "fileReads": 0 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 1, + "historyLength": 1, + "commands": { + "plugin:event|listen": 25, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 366.59999990463257, + "jsBytes": 6466606, + "longTaskMs": 193, + "webglContexts": 0 + }, + "refresh": { + "readyMs": 198.39999997615814, + "jsBytes": 6466606, + "longTaskMs": 0, + "webglContexts": 0 + }, + "firstDraw": { + "stateReadyMs": 40.299999952316284, + "paintMs": 118.19999992847443, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 9, + "paintMs": 23, + "fileReads": 0 + }, + "returnDraw": { + "stateReadyMs": 19.699999928474426, + "paintMs": 86.5, + "fileReads": 0 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 1, + "historyLength": 1, + "commands": { + "plugin:event|listen": 25, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 348.7000000476837, + "jsBytes": 6466606, + "longTaskMs": 184, + "webglContexts": 0 + }, + "refresh": { + "readyMs": 197.89999997615814, + "jsBytes": 6466606, + "longTaskMs": 0, + "webglContexts": 0 + }, + "firstDraw": { + "stateReadyMs": 37.700000047683716, + "paintMs": 115.60000002384186, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 9.300000071525574, + "paintMs": 26.40000009536743, + "fileReads": 0 + }, + "returnDraw": { + "stateReadyMs": 18.700000047683716, + "paintMs": 84.90000009536743, + "fileReads": 0 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 1, + "historyLength": 1, + "commands": { + "plugin:event|listen": 25, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 349.60000002384186, + "jsBytes": 6466606, + "longTaskMs": 179, + "webglContexts": 0 + }, + "refresh": { + "readyMs": 198.10000002384186, + "jsBytes": 6466606, + "longTaskMs": 0, + "webglContexts": 0 + }, + "firstDraw": { + "stateReadyMs": 38.300000071525574, + "paintMs": 118.5, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 11, + "paintMs": 19.600000023841858, + "fileReads": 0 + }, + "returnDraw": { + "stateReadyMs": 20.700000047683716, + "paintMs": 90.10000002384186, + "fileReads": 0 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 1, + "historyLength": 1, + "commands": { + "plugin:event|listen": 25, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 350.60000002384186, + "jsBytes": 6466606, + "longTaskMs": 179, + "webglContexts": 0 + }, + "refresh": { + "readyMs": 198.20000004768372, + "jsBytes": 6466606, + "longTaskMs": 0, + "webglContexts": 0 + }, + "firstDraw": { + "stateReadyMs": 39.60000002384186, + "paintMs": 120, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 11, + "paintMs": 22.199999928474426, + "fileReads": 0 + }, + "returnDraw": { + "stateReadyMs": 20, + "paintMs": 87, + "fileReads": 0 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 1, + "historyLength": 1, + "commands": { + "plugin:event|listen": 25, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 349.89999997615814, + "jsBytes": 6466606, + "longTaskMs": 180, + "webglContexts": 0 + }, + "refresh": { + "readyMs": 198.80000007152557, + "jsBytes": 6466606, + "longTaskMs": 0, + "webglContexts": 0 + }, + "firstDraw": { + "stateReadyMs": 38, + "paintMs": 115.30000007152557, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 8.200000047683716, + "paintMs": 24.899999976158142, + "fileReads": 0 + }, + "returnDraw": { + "stateReadyMs": 18.800000071525574, + "paintMs": 84.5, + "fileReads": 0 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 1, + "historyLength": 1, + "commands": { + "plugin:event|listen": 25, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + } + ], + "importedPages": [ + "src/pages/PageAngles/index.ts", + "src/pages/PageAudio/CreateAudio.tsx", + "src/pages/PageBackgroundChange/index.ts", + "src/pages/PageImage/TextToImage.tsx", + "src/pages/PageImageTo3DObject/index.ts", + "src/pages/PageImageTo3DWorld/index.ts", + "src/pages/PageImageWatermarkRemover/index.ts", + "src/pages/PageMoodboard/index.ts", + "src/pages/PageRemoveBackground/index.ts", + "src/pages/PageScene/index.tsx", + "src/pages/PageStoryboard/index.ts", + "src/pages/PageVideo/ImageToVideo.tsx", + "src/pages/PageVideoEditor/index.tsx", + "src/pages/PageVideoFrameExtractor/index.ts", + "src/pages/PageVideoWatermarkRemover/index.ts" + ], + "errors": [] +} diff --git a/docs/performance/2026-09-26-before.json b/docs/performance/2026-09-26-before.json new file mode 100644 index 0000000000..1ba0029ccf --- /dev/null +++ b/docs/performance/2026-09-26-before.json @@ -0,0 +1,510 @@ +{ + "timestamp": "2026-09-27T00:30:21.217Z", + "build": { + "builtAt": "2026-09-27T00:28:39.209Z", + "revision": "780e4494e995163fed1d677b88c3cf03b3fca63c", + "baselineOverlay": "HEAD", + "changedFiles": [ + "frontend/apps/artcraft/app/src/pages/MainApp.tsx", + "frontend/apps/artcraft/app/src/pages/Stores/TabState.ts", + "frontend/package-lock.json", + "frontend/package.json" + ] + }, + "environment": { + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M4 Pro", + "browser": "153.0.8010.53", + "node": "v24.13.0", + "runs": 7, + "viewport": "1440x1000@1", + "ipc": "mocked", + "network": "local assets only, browser cache disabled" + }, + "summary": { + "startup": { + "readyMs": { + "median": 566.3999999761581, + "min": 532.3999999761581, + "max": 847.7999999523163 + }, + "jsBytes": { + "median": 9052991, + "min": 9052991, + "max": 9052991 + }, + "longTaskMs": { + "median": 336, + "min": 314, + "max": 407 + }, + "webglContexts": { + "median": 1, + "min": 1, + "max": 1 + } + }, + "refresh": { + "readyMs": { + "median": 248.60000002384186, + "min": 247.59999990463257, + "max": 351.1999999284744 + }, + "jsBytes": { + "median": 9052991, + "min": 9052991, + "max": 9052991 + }, + "longTaskMs": { + "median": 0, + "min": 0, + "max": 0 + }, + "webglContexts": { + "median": 1, + "min": 1, + "max": 1 + } + }, + "firstDraw": { + "stateReadyMs": { + "median": 40.40000009536743, + "min": 37.299999952316284, + "max": 43.299999952316284 + }, + "paintMs": { + "median": 120.90000009536743, + "min": 117.89999997615814, + "max": 126.79999995231628 + }, + "fileReads": { + "median": 0, + "min": 0, + "max": 0 + } + }, + "leaveDraw": { + "stateReadyMs": { + "median": 23.399999976158142, + "min": 20.100000023841858, + "max": 26 + }, + "paintMs": { + "median": 29.5, + "min": 21.200000047683716, + "max": 35.5 + }, + "fileReads": { + "median": 4, + "min": 4, + "max": 4 + } + }, + "returnDraw": { + "stateReadyMs": { + "median": 42.10000002384186, + "min": 36.59999990463257, + "max": 45.200000047683716 + }, + "paintMs": { + "median": 105.69999992847443, + "min": 100.89999997615814, + "max": 109.20000004768372 + }, + "fileReads": { + "median": 4, + "min": 4, + "max": 4 + } + } + }, + "samples": [ + { + "startup": { + "readyMs": 847.7999999523163, + "jsBytes": 9052991, + "longTaskMs": 407, + "webglContexts": 1 + }, + "refresh": { + "readyMs": 351.1999999284744, + "jsBytes": 9052991, + "longTaskMs": 0, + "webglContexts": 1 + }, + "firstDraw": { + "stateReadyMs": 43.299999952316284, + "paintMs": 126.79999995231628, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 25.100000023841858, + "paintMs": 35.5, + "fileReads": 4 + }, + "returnDraw": { + "stateReadyMs": 36.59999990463257, + "paintMs": 100.89999997615814, + "fileReads": 4 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 0, + "historyLength": 2, + "commands": { + "plugin:event|listen": 26, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 539.3999999761581, + "jsBytes": 9052991, + "longTaskMs": 317, + "webglContexts": 1 + }, + "refresh": { + "readyMs": 264.60000002384186, + "jsBytes": 9052991, + "longTaskMs": 0, + "webglContexts": 1 + }, + "firstDraw": { + "stateReadyMs": 39.39999997615814, + "paintMs": 120.20000004768372, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 20.100000023841858, + "paintMs": 21.200000047683716, + "fileReads": 4 + }, + "returnDraw": { + "stateReadyMs": 41.60000002384186, + "paintMs": 104.60000002384186, + "fileReads": 4 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 0, + "historyLength": 2, + "commands": { + "plugin:event|listen": 26, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 561.9000000953674, + "jsBytes": 9052991, + "longTaskMs": 329, + "webglContexts": 1 + }, + "refresh": { + "readyMs": 248.19999992847443, + "jsBytes": 9052991, + "longTaskMs": 0, + "webglContexts": 1 + }, + "firstDraw": { + "stateReadyMs": 40.40000009536743, + "paintMs": 120.90000009536743, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 23.399999976158142, + "paintMs": 33.39999997615814, + "fileReads": 4 + }, + "returnDraw": { + "stateReadyMs": 37.299999952316284, + "paintMs": 101.10000002384186, + "fileReads": 4 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 0, + "historyLength": 2, + "commands": { + "plugin:event|listen": 26, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 569.5, + "jsBytes": 9052991, + "longTaskMs": 336, + "webglContexts": 1 + }, + "refresh": { + "readyMs": 248, + "jsBytes": 9052991, + "longTaskMs": 0, + "webglContexts": 1 + }, + "firstDraw": { + "stateReadyMs": 38, + "paintMs": 119.30000007152557, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 26, + "paintMs": 34.799999952316284, + "fileReads": 4 + }, + "returnDraw": { + "stateReadyMs": 45.200000047683716, + "paintMs": 109.20000004768372, + "fileReads": 4 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 0, + "historyLength": 2, + "commands": { + "plugin:event|listen": 26, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 567.5, + "jsBytes": 9052991, + "longTaskMs": 339, + "webglContexts": 1 + }, + "refresh": { + "readyMs": 248.60000002384186, + "jsBytes": 9052991, + "longTaskMs": 0, + "webglContexts": 1 + }, + "firstDraw": { + "stateReadyMs": 40.89999997615814, + "paintMs": 122.10000002384186, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 23.200000047683716, + "paintMs": 29.5, + "fileReads": 4 + }, + "returnDraw": { + "stateReadyMs": 42.10000002384186, + "paintMs": 105.69999992847443, + "fileReads": 4 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 0, + "historyLength": 2, + "commands": { + "plugin:event|listen": 26, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 566.3999999761581, + "jsBytes": 9052991, + "longTaskMs": 336, + "webglContexts": 1 + }, + "refresh": { + "readyMs": 281.39999997615814, + "jsBytes": 9052991, + "longTaskMs": 0, + "webglContexts": 1 + }, + "firstDraw": { + "stateReadyMs": 43.09999990463257, + "paintMs": 124.69999992847443, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 23.399999976158142, + "paintMs": 26.199999928474426, + "fileReads": 4 + }, + "returnDraw": { + "stateReadyMs": 42.799999952316284, + "paintMs": 107.19999992847443, + "fileReads": 4 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 0, + "historyLength": 2, + "commands": { + "plugin:event|listen": 26, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + }, + { + "startup": { + "readyMs": 532.3999999761581, + "jsBytes": 9052991, + "longTaskMs": 314, + "webglContexts": 1 + }, + "refresh": { + "readyMs": 247.59999990463257, + "jsBytes": 9052991, + "longTaskMs": 0, + "webglContexts": 1 + }, + "firstDraw": { + "stateReadyMs": 37.299999952316284, + "paintMs": 117.89999997615814, + "fileReads": 0 + }, + "leaveDraw": { + "stateReadyMs": 21.5, + "paintMs": 22.899999976158142, + "fileReads": 4 + }, + "returnDraw": { + "stateReadyMs": 44.09999990463257, + "paintMs": 108.39999997615814, + "fileReads": 4 + }, + "fixture": { + "images": 4, + "imageSize": 1024, + "imageBytesEach": 2356706, + "strokes": 80, + "coordinatesPerStroke": 200 + }, + "afterReturn": { + "nodeCount": 84, + "selectedCount": 0, + "historyLength": 2, + "commands": { + "plugin:event|listen": 26, + "get_app_info_command": 1, + "list_image_models_command": 1, + "list_video_models_command": 1, + "get_task_queue_command": 1, + "estimate_image_cost_command": 5, + "storyteller_get_login_session_command": 1, + "plugin:http|fetch": 5, + "storyteller_get_credits_command": 1, + "storyteller_get_subscription_command": 1, + "plugin:window|is_maximized": 1, + "plugin:event|unlisten": 2 + } + } + } + ], + "errors": [] +} diff --git a/docs/website-login.md b/docs/website-login.md new file mode 100644 index 0000000000..2015efaefd --- /dev/null +++ b/docs/website-login.md @@ -0,0 +1,80 @@ +# Website login from the desktop app + +The React login/signup dialog calls Tauri commands only, including its startup +session check and password forms. Website and QR actions appear only in login +mode. Tauri never connects to MySQL; the desktop uses Rust HTTP API bindings. Creating/polling login challenges and +verifying `/v1/session` use `artcraft_client::endpoints::users::login_challenges`. +The Rust command uses `AppEnvConfigs.storyteller_host` for every request, keeps the +private device token in native memory, and returns only a local challenge handle, +approval URL/code/deadline, status, and the verified user to JavaScript. The signed +session cookie never crosses IPC or gets written to JavaScript local storage. + +After redemption, Rust verifies the signed session against the same API host, +installs the server's cookie into the native HTTP jar and credential manager, and +persists the jar. Closing the dialog removes the local handle; a cancellation +while HTTP is in flight prevents installing the late response. Retries retrieve +the same server session. Neither creation nor website approval pre-creates one. + +## Session response compatibility + +A server feature flag added after a desktop release must not prevent login. The +session decoder keeps known feature flags and ignores unknown flag names, such +as `use_qt` on older clients. Malformed flag values and required authentication +fields still fail validation. Tests cover a real Rust HTTP session response with +both known and future flags, followed by native cookie installation/persistence. +A late startup session check cannot reset an active or completed website login. + +## Diagnosing 401 responses + +`storyteller_activity_thread` reports errors from `/v1/analytics/active_user_v2`. +This is the background activity ping, not the login-challenge endpoint. It requires +a user session. An anonymous `visitor` cookie alone is now skipped. Expired or +revoked sessions can still produce a 401 and require signing in again. + +Activity failures log the configured API origin (including port), endpoint, and +error. Native website-login requests/failures log origin, endpoint, and HTTP +status. Request bodies, device tokens, approval URLs, and signed cookies are not +logged by the new client, including at debug level. + +## API environment + +`~/Artcraft/settings/env_configs.json` controls the native API host: + +```json +{"storyteller_host":"localhost","storyteller_port":12345} +``` + +Local mode uses `http://localhost:12345` and a local approval website at +`http://localhost:4201/login/desktop`. Run storyteller-web with its migrations +applied, and the services webapp with `USE_LOCAL_API=1`. The local website must +also use the local API. A challenge in a local database cannot be approved on the +production website, even when that browser already has a production session. + +For the production API and production website, use `"storyteller_host":"production"` +(or omit the override), then restart ArtCraft. API host configuration is not +silently changed by login. Local and production approval URLs cannot be mixed. +Localhost development URLs are for the same computer; mobile QR testing needs +a shared/deployed environment. + +## Tests + +From the desktop repository: + +```sh +SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests +cd frontend +npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx +``` + +Native tests use only ephemeral loopback HTTP servers and temporary cookie stores. +They exercise the real API bindings and the command implementation, including +cookie verification/persistence, rejection, expiry, retries, in-flight cancellation, +origin separation, no redirects, redacted errors, visitor-only telemetry, future +feature flags, malformed session data, and password login/signup/session checks. +UI tests prohibit JavaScript HTTP and runtime API imports. They verify the IPC +contract, native browser opening, QR/code display, status, backoff, cleanup, +login-only button placement, native password forms, and stale session responses. + +The services repository also has real MySQL integration tests for approval, +redemption, rejection, concurrency, and downstream authentication, using isolated +local databases. No test contacts a production API or database. diff --git a/frontend/apps/artcraft/app/src/pages/MainApp.tsx b/frontend/apps/artcraft/app/src/pages/MainApp.tsx index 3db83f71cf..b35bebd13a 100644 --- a/frontend/apps/artcraft/app/src/pages/MainApp.tsx +++ b/frontend/apps/artcraft/app/src/pages/MainApp.tsx @@ -10,8 +10,7 @@ // siblings, never wrapped by EngineProvider, so they have no // dependency on the 3D engine being present. -import { useEffect, useState } from "react"; -import * as gpu from "detect-gpu"; +import { lazy, Suspense, useEffect } from "react"; import { useSignals } from "@preact/signals-react/runtime"; import { TopBar } from "~/components"; @@ -58,26 +57,55 @@ import { useTextToImageStore } from "./PageImage/TextToImageStore"; import { AppsIndexPage } from "./PageApps/AppsIndexPage"; import PageDraw from "./PageDraw/PageDraw"; -import TextToImage from "./PageImage/TextToImage"; -import ImageToVideo from "./PageVideo/ImageToVideo"; -import CreateAudio from "./PageAudio/CreateAudio"; -import { VideoFrameExtractor } from "./PageVideoFrameExtractor"; -import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover"; -import { ImageWatermarkRemover } from "./PageImageWatermarkRemover"; -import { ImageTo3DObject } from "./PageImageTo3DObject"; -import { ImageTo3DWorld } from "./PageImageTo3DWorld"; -import { RemoveBackground } from "./PageRemoveBackground"; -import { Angles } from "./PageAngles"; -import { Storyboard } from "./PageStoryboard"; -import { PageBackgroundChange } from "./PageBackgroundChange"; -import { PageScene } from "./PageScene"; -import { PageVideoEditor } from "./PageVideoEditor"; -import { PageMoodboard } from "./PageMoodboard"; import { topNavMediaId, topNavMediaUrl, } from "~/components/signaled/TopBar/TopBar"; +// Load editors when selected, keeping their code off the launch path. Drawing +// stays eager: its shared store/UI is already needed by the shell, so deferring +// its small host adapter adds first-open latency for very little byte saving. +// Declare lazy components once so navigating does not recreate their identity. +const TextToImage = lazy(() => import("./PageImage/TextToImage")); +const ImageToVideo = lazy(() => import("./PageVideo/ImageToVideo")); +const CreateAudio = lazy(() => import("./PageAudio/CreateAudio")); +const VideoFrameExtractor = lazy(() => + import("./PageVideoFrameExtractor").then((m) => ({ default: m.VideoFrameExtractor })), +); +const VideoWatermarkRemover = lazy(() => + import("./PageVideoWatermarkRemover").then((m) => ({ default: m.VideoWatermarkRemover })), +); +const ImageWatermarkRemover = lazy(() => + import("./PageImageWatermarkRemover").then((m) => ({ default: m.ImageWatermarkRemover })), +); +const ImageTo3DObject = lazy(() => + import("./PageImageTo3DObject").then((m) => ({ default: m.ImageTo3DObject })), +); +const ImageTo3DWorld = lazy(() => + import("./PageImageTo3DWorld").then((m) => ({ default: m.ImageTo3DWorld })), +); +const RemoveBackground = lazy(() => + import("./PageRemoveBackground").then((m) => ({ default: m.RemoveBackground })), +); +const Angles = lazy(() => + import("./PageAngles").then((m) => ({ default: m.Angles })), +); +const Storyboard = lazy(() => + import("./PageStoryboard").then((m) => ({ default: m.Storyboard })), +); +const PageBackgroundChange = lazy(() => + import("./PageBackgroundChange").then((m) => ({ default: m.PageBackgroundChange })), +); +const PageScene = lazy(() => + import("./PageScene").then((m) => ({ default: m.PageScene })), +); +const PageVideoEditor = lazy(() => + import("./PageVideoEditor").then((m) => ({ default: m.PageVideoEditor })), +); +const PageMoodboard = lazy(() => + import("./PageMoodboard").then((m) => ({ default: m.PageMoodboard })), +); + interface Props { sceneToken?: string; } @@ -121,8 +149,7 @@ export const MainApp = ({ sceneToken }: Props) => { toast.error("File deleted."); }); - // Session probe (runs once per shell mount) and GPU detection. - // Both are app-wide concerns, not 3D-only. + // Session probe (runs once per shell mount). useEffect(() => { const usersApi = new UsersApi(); usersApi.GetSession().then((result) => { @@ -132,20 +159,6 @@ export const MainApp = ({ sceneToken }: Props) => { }); }, []); - const [, setValidGpu] = useState("unknown"); - useEffect(() => { - const { getGPUTier } = gpu; - getGPUTier().then((gpuTier) => { - console.log("GPU tier", gpuTier); - let isValid = false; - const fps = gpuTier.fps || 0; - if (gpuTier.tier > 1) isValid = true; - if (fps > 15) isValid = true; - if (gpuTier.gpu === "apple gpu (Apple GPU)") isValid = true; - setValidGpu(isValid ? "valid" : "error"); - }); - }, []); - const { triggerRecheck } = useLoginModalStore(); const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } = useCreditsModalStore(); @@ -179,7 +192,18 @@ export const MainApp = ({ sceneToken }: Props) => { }} /> - + + Loading… + + } + > + + @@ -210,7 +234,7 @@ export const MainApp = ({ sceneToken }: Props) => { }; const TabBody = ({ sceneToken }: { sceneToken?: string }) => { - const tabStore = useTabStore(); + const activeTabId = useTabStore((s) => s.activeTabId); const storyboardPageEnabled = useStoryboardPageEnabled(); // The 3D case stays unwrapped because Stage3DBody (lib) already @@ -218,7 +242,7 @@ const TabBody = ({ sceneToken }: { sceneToken?: string }) => { // top-level children may use position: fixed (e.g. PageDraw); the // wrapping
scopes them so they don't stack as siblings of // the TopBar at the MainApp root. - switch (tabStore.activeTabId) { + switch (activeTabId) { case "3D": return ; case "APPS": diff --git a/frontend/apps/artcraft/app/src/pages/Stores/TabState.spec.ts b/frontend/apps/artcraft/app/src/pages/Stores/TabState.spec.ts new file mode 100644 index 0000000000..62f2bdc427 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/Stores/TabState.spec.ts @@ -0,0 +1,84 @@ +import { beforeEach, expect, it } from "vitest"; +import { useTabStore } from "./TabState"; +import { useSceneStore } from "../../../../../../libs/components/pagedraw/src/lib/stores/SceneState"; +import { Node } from "../../../../../../libs/components/pagedraw/src/lib/Node"; + +const initialScene = useSceneStore.getInitialState(); +const initialTabs = useTabStore.getInitialState(); + +beforeEach(() => { + useSceneStore.setState(initialScene, true); + useTabStore.setState(initialTabs, true); +}); + +it("retains decoded images, selection, model metadata, and history across tab changes", async () => { + const image = new Node({ + id: "image", type: "image", x: 10, y: 20, width: 512, height: 512, + fill: "transparent", imageFile: new File(["pixels"], "artwork.png"), + imageElement: new Image(), modelUrl: "blob:model", + }); + useSceneStore.setState({ drawNodes: [image], selectedNodeIds: [image.id] }); + useSceneStore.getState().saveState(); + const before = useSceneStore.getState(); + + await useTabStore.getState().setActiveTab("2D"); + await useTabStore.getState().setActiveTab("APPS"); + await useTabStore.getState().setActiveTab("2D"); + + const after = useSceneStore.getState(); + expect(after.drawNodes).toBe(before.drawNodes); + expect(after.drawNodes[0]).toBe(image); + expect(after.selectedNodeIds).toEqual([image.id]); + expect(after.history).toBe(before.history); + expect(after.historyIndex).toBe(before.historyIndex); +}); + +it("keeps redo available when navigating away after undo", async () => { + const scene = useSceneStore.getState(); + useSceneStore.setState({ drawNodes: [rectangle("first")] }); + scene.saveState(); + useSceneStore.setState({ drawNodes: [rectangle("first"), rectangle("second")] }); + scene.saveState(); + await scene.undo(); + + await useTabStore.getState().setActiveTab("2D"); + await useTabStore.getState().setActiveTab("IMAGE"); + await useTabStore.getState().setActiveTab("2D"); + await useSceneStore.getState().redo(); + + expect(useSceneStore.getState().drawNodes.map((node) => node.id)).toEqual(["first", "second"]); +}); + +it("keeps edits made while away and does not restore an old scene after a gallery reset", async () => { + useSceneStore.setState({ drawNodes: [rectangle("old")] }); + await useTabStore.getState().setActiveTab("2D"); + await useTabStore.getState().setActiveTab("APPS"); + useSceneStore.getState().RESET(); + useSceneStore.setState({ drawNodes: [rectangle("new")] }); + await useTabStore.getState().setActiveTab("2D"); + expect(useSceneStore.getState().drawNodes.map((node) => node.id)).toEqual(["new"]); +}); + +it("honors the latest rapid navigation request", async () => { + await useTabStore.getState().setActiveTab("2D"); + const results = await Promise.all([ + useTabStore.getState().setActiveTab("IMAGE"), + useTabStore.getState().setActiveTab("VIDEO"), + ]); + expect(results).toEqual([true, true]); + expect(useTabStore.getState().activeTabId).toBe("VIDEO"); +}); + +it("still exports a portable scene when explicitly requested", async () => { + const image = new Node({ + id: "image", type: "image", x: 0, y: 0, width: 1, height: 1, + fill: "transparent", imageFile: new File(["pixels"], "image.png", { type: "image/png" }), + }); + useSceneStore.setState({ drawNodes: [image] }); + const exported = JSON.parse(await useSceneStore.getState().exportSceneAsJson()); + expect(exported.drawNodes[0].imageDataUrl).toBe("data:image/png;base64,cGl4ZWxz"); +}); + +function rectangle(id: string) { + return new Node({ id, type: "rectangle", x: 0, y: 0, width: 100, height: 100, fill: "red" }); +} diff --git a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts index d7c2652a3f..e42a04fff3 100644 --- a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts +++ b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts @@ -1,5 +1,4 @@ import { create } from "zustand"; -import { useSceneStore } from "@storyteller/ui-pagedraw"; export type TabId = | "2D" @@ -42,40 +41,14 @@ export const useTabStore = create((set, get) => ({ tabData: {}, setActiveTab: async (newTabId) => { - const currentTabId = get().activeTabId; - // Don't do anything if we're already on this tab - if (currentTabId === newTabId) return true; + if (get().activeTabId === newTabId) return true; - try { - // Save current 2D state if we're leaving 2D tab - if (currentTabId === "2D") { - const sceneStore = useSceneStore.getState(); - const sceneState = await sceneStore.serializeSceneToString(); - set((state) => ({ - tabData: { - ...state.tabData, - "2D": sceneState, - }, - })); - } - - // Load 2D state if we're entering 2D tab - if (newTabId === "2D") { - const savedState = get().tabData["2D"]; - if (savedState) { - const sceneStore = useSceneStore.getState(); - sceneStore.loadSceneFromString(savedState); - } - } - - // Update active tab - set({ activeTabId: newTabId }); - return true; - } catch (error) { - console.error("Error during tab change:", error); - return false; - } + // The drawing store outlives its React page. Keep its live image objects, + // selection, and undo history when switching tabs. Export/import is for + // files; round-tripping here reread every image and rebuilt the scene. + set({ activeTabId: newTabId }); + return true; }, updateTabData: (tabId, data) => { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bdd555656c..18561a8b56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -79,6 +79,7 @@ "@nx/vite": "21.2.3", "@nx/web": "21.2.3", "@nx/workspace": "21.2.3", + "@playwright/test": "1.63.0", "@remix-run/node": "^2.16.5", "@swc-node/register": "~1.9.1", "@swc/core": "~1.5.7", @@ -5602,6 +5603,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -19977,6 +19994,35 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", diff --git a/frontend/package.json b/frontend/package.json index 331f036e43..d2839da1a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,9 @@ "name": "@frontend/source", "version": "0.0.0", "license": "MIT", - "scripts": {}, + "scripts": { + "perf:desktop": "node tools/performance/run.mjs" + }, "private": true, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -68,6 +70,7 @@ "@nx/vite": "21.2.3", "@nx/web": "21.2.3", "@nx/workspace": "21.2.3", + "@playwright/test": "1.63.0", "@remix-run/node": "^2.16.5", "@swc-node/register": "~1.9.1", "@swc/core": "~1.5.7", diff --git a/frontend/tools/performance/benchmark-plugin.mjs b/frontend/tools/performance/benchmark-plugin.mjs new file mode 100644 index 0000000000..6ea67b52b9 --- /dev/null +++ b/frontend/tools/performance/benchmark-plugin.mjs @@ -0,0 +1,54 @@ +import { execFileSync } from "node:child_process"; +import { relative } from "node:path"; + +// Benchmark-only instrumentation. The normal desktop build never uses this plugin. +export function benchmarkPlugin({ baselineRef, frontend } = {}) { + // Reproduce this experiment without checking out over local work. This is a + // two-module overlay, not a build of an entire historical repository. + const baseline = new Map(); + if (baselineRef) { + for (const path of ["pages/MainApp.tsx", "pages/Stores/TabState.ts"]) { + baseline.set(`/apps/artcraft/app/src/${path}`, execFileSync("git", ["show", `${baselineRef}:frontend/apps/artcraft/app/src/${path}`], { cwd: frontend, encoding: "utf8" })); + } + } + const exports = new Map([ + ["/pages/Stores/TabState.ts", ["tabs", "useTabStore"]], + ["/pagedraw/src/lib/stores/SceneState.ts", ["scene", "useSceneStore"]], + ["/pagedraw/src/lib/Node.ts", ["Node", "Node"]], + ]); + return { + name: "artcraft-performance-fixtures", + enforce: "pre", + transform(code, id) { + const source = [...baseline].find(([suffix]) => id.endsWith(suffix)); + if (source) code = source[1]; + for (const [suffix, [key, value]] of exports) { + if (id.endsWith(suffix)) { + return `${code}\nwindow.__ARTCRAFT_BENCH__ ??= {};\nwindow.__ARTCRAFT_BENCH__.${key} = ${value};\n`; + } + } + if (source) return code; + }, + generateBundle: { + order: "post", + handler(_options, bundle) { + const chunks = Object.values(bundle).filter((item) => item.type === "chunk"); + this.emitFile({ + type: "asset", + fileName: "performance-bundle.json", + source: JSON.stringify(chunks.map((chunk) => ({ + file: chunk.fileName, + entry: chunk.isEntry, + dynamicEntry: chunk.isDynamicEntry, + bytes: Buffer.byteLength(chunk.code), + imports: chunk.imports, + largestModules: Object.entries(chunk.modules) + .sort((a, b) => b[1].renderedLength - a[1].renderedLength) + .slice(0, 25) + .map(([id, module]) => ({ module: relative(frontend, id), renderedBytes: module.renderedLength })), + })), null, 2), + }); + }, + }, + }; +} diff --git a/frontend/tools/performance/browser-fixture.mjs b/frontend/tools/performance/browser-fixture.mjs new file mode 100644 index 0000000000..7f8025866f --- /dev/null +++ b/frontend/tools/performance/browser-fixture.mjs @@ -0,0 +1,103 @@ +// Runs before any application script. No real account, Rust IPC, or remote API. +export function installBrowserFixture() { + const counters = { commands: {}, fileReads: 0, webglContexts: 0, longTasks: [] }; + window.__ARTCRAFT_COUNTERS__ = counters; + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (type, ...args) { + if (type === "webgl" || type === "webgl2" || type === "experimental-webgl") counters.webglContexts++; + return getContext.call(this, type, ...args); + }; + const readAsDataURL = FileReader.prototype.readAsDataURL; + FileReader.prototype.readAsDataURL = function (...args) { + counters.fileReads++; + return readAsDataURL.apply(this, args); + }; + if (PerformanceObserver.supportedEntryTypes.includes("longtask")) { + new PerformanceObserver((list) => { + counters.longTasks.push(...list.getEntries().map(({ startTime, duration }) => ({ startTime, duration }))); + }).observe({ type: "longtask", buffered: true }); + } + let callbackId = 0; + const callbacks = new Map(); + window.__TAURI_INTERNALS__ = { + metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } }, + transformCallback(callback) { callbacks.set(++callbackId, callback); return callbackId; }, + unregisterCallback(id) { callbacks.delete(id); }, + convertFileSrc(path) { return path; }, + async invoke(command, args) { + counters.commands[command] = (counters.commands[command] ?? 0) + 1; + if (command === "plugin:event|listen") return args.handler; + if (command === "plugin:window|inner_size") return { width: 1440, height: 1000 }; + if (command === "plugin:window|scale_factor") return 1; + if (command === "storyteller_get_login_session_command") return { + username: "performance_fixture", user_token: "u_performance_fixture", + }; + if (command === "get_task_queue_command") return { payload: { tasks: [] } }; + // Use the bundled model overlay, as the app does when the listing is unavailable. + if (command === "list_image_models_command" || command === "list_video_models_command") throw new Error("Offline model listing fixture"); + if (command === "plugin:app|version") return "0.0.0-benchmark"; + if (command === "plugin:app|name") return "ArtCraft Benchmark"; + if (command === "plugin:os|platform") return "macos"; + if (command.startsWith("plugin:http|")) throw new Error("Network disabled in performance fixture"); + return { success: true, payload: {} }; + }, + }; + window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} }; +} + +export async function seedDrawingScene() { + const { scene, Node } = window.__ARTCRAFT_BENCH__; + scene.getState().RESET(); + scene.getState().setBaseImageInfo({ url: "", mediaToken: "fixture-blank", isBlankCanvas: true, blankCanvasWidth: 1024, blankCanvasHeight: 1024 }); + // Deterministic noisy pixels resist PNG compression, like imported artwork. + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1024; + const context = canvas.getContext("2d"); + const pixels = context.createImageData(1024, 1024); + let random = 12345; + for (let i = 0; i < pixels.data.length; i += 4) { + random = (Math.imul(random, 1664525) + 1013904223) >>> 0; + pixels.data[i] = random & 255; + pixels.data[i + 1] = (random >>> 8) & 255; + pixels.data[i + 2] = (random >>> 16) & 255; + pixels.data[i + 3] = 255; + } + context.putImageData(pixels, 0, 0); + const blob = await new Promise((resolve) => canvas.toBlob(resolve)); + const drawNodes = []; + for (let i = 0; i < 4; i++) { + const node = new Node({ id: `fixture-image-${i}`, type: "image", x: i * 220, y: i * 100, width: 400, height: 400, fill: "transparent" }); + await node.setImageFromFile(new File([blob], `fixture-${i}.png`, { type: "image/png" })); + drawNodes.push(node); + } + for (let i = 0; i < 80; i++) { + drawNodes.push({ + id: `fixture-line-${i}`, type: "line", draggable: true, stroke: "#7766ee", strokeWidth: 3, + points: Array.from({ length: 200 }, (_, j) => j % 2 === 0 ? j * 4 : i * 10 + Math.sin(j) * 30), + }); + } + scene.setState({ drawNodes, selectedNodeIds: [drawNodes[0].id] }); + scene.getState().saveState(); + return { images: 4, imageSize: 1024, imageBytesEach: blob.size, strokes: 80, coordinatesPerStroke: 200 }; +} + +export async function measureTabChange(target) { + const counters = window.__ARTCRAFT_COUNTERS__; + const readsBefore = counters.fileReads; + const start = performance.now(); + const success = await window.__ARTCRAFT_BENCH__.tabs.getState().setActiveTab(target); + if (!success) throw new Error(`Tab change to ${target} failed`); + const stateReadyMs = performance.now() - start; + const ready = () => target === "2D" + ? !!document.querySelector(".konvajs-content canvas") + : !!Array.from(document.querySelectorAll("h1")).find((h) => h.textContent.includes("craft")); + while (!ready()) { + if (performance.now() - start > 15000) throw new Error(`Tab ${target} never rendered`); + await new Promise(requestAnimationFrame); + } + // A rendering opportunity after the target DOM committed, not GPU completion. + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const paintMs = performance.now() - start; + performance.measure(`artcraft:tab:${target}`, { start, end: performance.now() }); + return { stateReadyMs, paintMs, fileReads: counters.fileReads - readsBefore }; +} diff --git a/frontend/tools/performance/compare.mjs b/frontend/tools/performance/compare.mjs new file mode 100644 index 0000000000..aaf8970127 --- /dev/null +++ b/frontend/tools/performance/compare.mjs @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; + +const [beforePath, afterPath] = process.argv.slice(2); +if (!beforePath || !afterPath) throw new Error("Usage: node tools/performance/compare.mjs before.json after.json"); +const before = JSON.parse(await readFile(beforePath, "utf8")); +const after = JSON.parse(await readFile(afterPath, "utf8")); +for (const field of ["platform", "arch", "cpu", "browser", "viewport", "ipc", "network", "runs"]) { + if (before.environment[field] !== after.environment[field]) throw new Error(`Different ${field}; do not compare these runs`); +} +if (before.errors.length || after.errors.length) throw new Error("A run contains browser errors"); +if (JSON.stringify(before.samples[0].fixture) !== JSON.stringify(after.samples[0].fixture)) throw new Error("Different scene fixtures"); + +const rows = [["Measurement", "Before", "After", "Reduction"]]; +for (const [label, event, metric, unit, scale] of [ + ["Startup to frame", "startup", "readyMs", "ms", 1], + ["Refresh to frame", "refresh", "readyMs", "ms", 1], + ["Startup JavaScript", "startup", "jsBytes", "MB", 1e6], + ["Startup long tasks (sum)", "startup", "longTaskMs", "ms", 1], + ["First drawing page to frame", "firstDraw", "paintMs", "ms", 1], + ["Drawing to home: dispatch", "leaveDraw", "stateReadyMs", "ms", 1], + ["Drawing to home: frame", "leaveDraw", "paintMs", "ms", 1], + ["Home to drawing: dispatch", "returnDraw", "stateReadyMs", "ms", 1], + ["Home to drawing: frame", "returnDraw", "paintMs", "ms", 1], +]) { + const a = before.summary[event][metric].median; + const b = after.summary[event][metric].median; + rows.push([label, `${(a / scale).toFixed(2)} ${unit}`, `${(b / scale).toFixed(2)} ${unit}`, `${((a - b) / a * 100).toFixed(1)}%`]); +} +const widths = rows[0].map((_, col) => Math.max(...rows.map((row) => row[col].length))); +const format = (row) => `| ${row.map((cell, col) => cell.padEnd(widths[col])).join(" | ")} |`; +console.log(format(rows[0])); +console.log(`|-${widths.map((w) => "-".repeat(w)).join("-|-")}-|`); +for (const row of rows.slice(1)) console.log(format(row)); diff --git a/frontend/tools/performance/run.mjs b/frontend/tools/performance/run.mjs new file mode 100644 index 0000000000..444b52c796 --- /dev/null +++ b/frontend/tools/performance/run.mjs @@ -0,0 +1,125 @@ +import { build, preview } from "vite"; +import { chromium } from "@playwright/test"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import os from "node:os"; +import { execFileSync } from "node:child_process"; +import { parseArgs } from "node:util"; +import { benchmarkPlugin } from "./benchmark-plugin.mjs"; +import { installBrowserFixture, seedDrawingScene, measureTabChange } from "./browser-fixture.mjs"; + +const frontend = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const { values } = parseArgs({ options: { + output: { type: "string", default: process.env.ARTCRAFT_PERF_DIR || "/tmp/artcraft-performance" }, + runs: { type: "string", default: process.env.ARTCRAFT_PERF_RUNS || "7" }, + "skip-build": { type: "boolean", default: !!process.env.ARTCRAFT_PERF_SKIP_BUILD }, + "build-only": { type: "boolean", default: false }, + channel: { type: "string", default: "chrome" }, + "baseline-ref": { type: "string" }, + "smoke-imports": { type: "boolean", default: false }, +} }); +const output = resolve(values.output); +const runs = Number(values.runs); +if (!Number.isInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer"); +const configFile = resolve(frontend, "apps/artcraft/vite.config.ts"); +// Match `npm run build` in the app: Tailwind resolves its config/content from cwd. +process.chdir(dirname(configFile)); +await mkdir(output, { recursive: true }); +if (!values["skip-build"]) { + await build({ configFile, plugins: [benchmarkPlugin({ baselineRef: values["baseline-ref"], frontend })], build: { outDir: resolve(output, "dist"), manifest: true } }); + await writeFile(resolve(output, "build-info.json"), JSON.stringify({ + builtAt: new Date().toISOString(), + revision: execFileSync("git", ["rev-parse", "HEAD"], { cwd: frontend, encoding: "utf8" }).trim(), + baselineOverlay: values["baseline-ref"] || null, + changedFiles: execFileSync("git", ["diff", "--name-only"], { cwd: frontend, encoding: "utf8" }).trim().split("\n"), + }, null, 2)); +} +if (values["build-only"]) process.exit(0); +const server = await preview({ configFile, build: { outDir: resolve(output, "dist") }, preview: { host: "127.0.0.1", port: 0, strictPort: false } }); +let browser; +try { + browser = await chromium.launch({ channel: values.channel, headless: true }); + const url = server.resolvedUrls.local[0]; + const samples = []; + const errors = []; + const importedPages = []; + for (let run = 0; run < runs; run++) { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 }); + await context.addInitScript(installBrowserFixture); + const page = await context.newPage(); + page.on("pageerror", (error) => { errors.push(error.message); console.error("Browser error:", error.message); }); + // No analytics, external media, or authenticated API traffic in this fixture. + await context.route("**/*", (route) => route.request().url().startsWith(url) || route.request().url().startsWith("blob:") + ? route.continue() : route.abort()); + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.getByRole("heading", { name: "What will you craft today?" }).waitFor(); + await page.waitForFunction(() => !document.querySelector("video")); + if (!await page.evaluate(() => getComputedStyle(document.querySelector(".fixed")).position === "fixed")) { + throw new Error("Tailwind styles are missing; benchmark layout is invalid"); + } + const collectStartup = async () => page.evaluate(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { + readyMs: performance.now(), + jsBytes: performance.getEntriesByType("resource").filter((r) => r.name.endsWith(".js")).reduce((sum, r) => sum + r.decodedBodySize, 0), + longTaskMs: window.__ARTCRAFT_COUNTERS__.longTasks.reduce((sum, t) => sum + t.duration, 0), + webglContexts: window.__ARTCRAFT_COUNTERS__.webglContexts, + }; + }); + const startup = await collectStartup(); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.getByRole("heading", { name: "What will you craft today?" }).waitFor(); + await page.waitForFunction(() => !document.querySelector("video")); + const refresh = await collectStartup(); + const fixture = await page.evaluate(seedDrawingScene); + const firstDraw = await page.evaluate(measureTabChange, "2D"); + // Allow image decoding and the canvas bake debounce to settle outside timing. + await page.waitForTimeout(1000); + const leaveDraw = await page.evaluate(measureTabChange, "APPS"); + const returnDraw = await page.evaluate(measureTabChange, "2D"); + await page.waitForTimeout(1000); + const afterReturn = await page.evaluate(() => ({ + nodeCount: window.__ARTCRAFT_BENCH__.scene.getState().drawNodes.length, + selectedCount: window.__ARTCRAFT_BENCH__.scene.getState().selectedNodeIds.length, + historyLength: window.__ARTCRAFT_BENCH__.scene.getState().history.length, + commands: window.__ARTCRAFT_COUNTERS__.commands, + })); + if (run === 0) await page.screenshot({ path: resolve(output, "drawing.png") }); + samples.push({ startup, refresh, firstDraw, leaveDraw, returnDraw, fixture, afterReturn }); + console.log(JSON.stringify({ run: run + 1, startup, refresh, firstDraw, leaveDraw, returnDraw })); + // Validate deferred module evaluation separately from all timed operations. + if (values["smoke-imports"] && run === runs - 1) { + const manifest = JSON.parse(await readFile(resolve(output, "dist/.vite/manifest.json"), "utf8")); + for (const [source, chunk] of Object.entries(manifest)) { + if (source.startsWith("src/pages/") && chunk.isDynamicEntry) { + await page.evaluate(async (file) => { await import(/* @vite-ignore */ `/${file}`); }, chunk.file); + importedPages.push(source); + } + } + } + await context.close(); + } + const summary = {}; + for (const event of ["startup", "refresh", "firstDraw", "leaveDraw", "returnDraw"]) { + summary[event] = {}; + for (const metric of Object.keys(samples[0][event])) { + const values = samples.map((s) => s[event][metric]).sort((a, b) => a - b); + summary[event][metric] = { median: values[Math.floor(values.length / 2)], min: values[0], max: values.at(-1) }; + } + } + const result = { + timestamp: new Date().toISOString(), build: JSON.parse(await readFile(resolve(output, "build-info.json"), "utf8")), + environment: { platform: os.platform(), arch: os.arch(), cpu: os.cpus()[0].model, browser: browser.version(), node: process.version, runs, viewport: "1440x1000@1", ipc: "mocked", network: "local assets only, browser cache disabled" }, + summary, samples, importedPages, errors: [...new Set(errors)], + }; + await writeFile(resolve(output, "results.json"), `${JSON.stringify(result, null, 2)}\n`); + console.log(JSON.stringify({ summary, errors: result.errors, output }, null, 2)); + if (errors.length) throw new Error("Browser errors invalidate this run; see results.json"); +} finally { + await browser?.close(); + await new Promise((resolve, reject) => server.httpServer.close((error) => error ? reject(error) : resolve())); +}