diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 544b280159de..0d1f3edf5ece 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,7 @@ jobs: run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }} strict_control_ui_i18n: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.strict_control_ui_i18n }} run_ui_tests: ${{ steps.manifest.outputs.run_ui_tests }} + ui_test_runtime_policy: ${{ steps.manifest.outputs.ui_test_runtime_policy }} run_control_ui_performance: ${{ steps.manifest.outputs.run_control_ui_performance }} run_ui_e2e: ${{ steps.manifest.outputs.run_ui_e2e }} ui_e2e_matrix: ${{ steps.manifest.outputs.ui_e2e_matrix }} @@ -1795,6 +1796,16 @@ jobs: ? "dual" : "node" : "node"; + const uiTestRuntimePolicy = !compatibilityTarget && testRuntimePolicy?.ciTestShardRequiresBun({ + configs: ["ui/vitest.config.ts"], + vitestArgs: [ + "--maxWorkers", "3", + "--reporter=verbose", + "--reporter=github-actions", + "--reporter=./scripts/lib/vitest-resource-reporter.mts", + ...(frozenTarget ? [] : ["--shard=1/3"]), + ], + }, testRuntimeMode) ? testRuntimeMode : "node"; const goToolingConfig = "test/vitest/vitest.tooling.config.ts"; const knownToolingConfigs = new Set([ goToolingConfig, @@ -1968,6 +1979,7 @@ jobs: run_format_check: runFormatCheck, run_control_ui_i18n: runControlUiI18n, run_ui_tests: runUiTests, + ui_test_runtime_policy: uiTestRuntimePolicy, run_control_ui_performance: runControlUiPerformance, run_ui_e2e: runUiE2e, ui_e2e_matrix: createMatrix(Array.from({ length: uiE2eJobCount }, (_, index) => { @@ -2855,6 +2867,10 @@ jobs: dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && runner.environment == 'self-hosted' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} restore-test-caches: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 'true' || 'false' }} + - name: Setup pinned Bun test runtime + if: needs.preflight.outputs.ui_test_runtime_policy == 'bun-compatible' || needs.preflight.outputs.ui_test_runtime_policy == 'dual' + uses: ./.ci-harness/.github/actions/setup-test-bun + - &cache_playwright_chromium name: Cache Playwright Chromium if: needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.compatibility_target != 'true' @@ -2888,6 +2904,7 @@ jobs: - name: Test Control UI env: + OPENCLAW_CI_TEST_RUNTIME_POLICY: ${{ needs.preflight.outputs.ui_test_runtime_policy }} OPENCLAW_UI_E2E_DIAGNOSTIC_DIR: ${{ github.workspace }}/.artifacts/control-ui-e2e-timeouts/ui-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }} OPENCLAW_NODE_TEST_CONFIGS_JSON: '["ui/vitest.config.ts"]' OPENCLAW_NODE_TEST_PLAN_CONCURRENCY: "1" diff --git a/.github/workflows/vitest-cache-warm.yml b/.github/workflows/vitest-cache-warm.yml index d9086292c06b..e319d730b2fd 100644 --- a/.github/workflows/vitest-cache-warm.yml +++ b/.github/workflows/vitest-cache-warm.yml @@ -105,6 +105,9 @@ jobs: vitest-fs-cache: "true" vitest-worker-cache: ${{ matrix.platform == 'linux' && 'true' || 'false' }} + - name: Setup pinned Bun test runtime + uses: ./.github/actions/setup-test-bun + # Match source-only shards before SDK/runtime builds change resolution topology. - name: Prepare compiled Vitest workers if: ${{ matrix.platform == 'linux' }} @@ -185,27 +188,6 @@ jobs: extensions/*/src/host/**/*.bundle.js key: ${{ runner.os }}-dist-build-v3-${{ github.sha }} - - name: Select cache seed - if: ${{ matrix.platform == 'linux' }} - shell: bash - run: | - set -euo pipefail - node --import tsx --input-type=module <<'EOF' - import { appendFileSync } from "node:fs"; - import { createVitestCacheWarmGroups } from "./scripts/lib/ci-node-test-plan.mts"; - - // Preserve the selected backend's consumer configs and include patterns. - const groups = createVitestCacheWarmGroups(); - appendFileSync( - process.env.GITHUB_ENV, - [ - `OPENCLAW_NODE_TEST_GROUPS_JSON=${JSON.stringify(groups)}`, - "OPENCLAW_NODE_TEST_PLAN_CONCURRENCY=1", - "OPENCLAW_NODE_TEST_PLAN_CONTINUE_ON_FAILURE=1", - ].join("\n") + "\n", - ); - EOF - - name: Warm transform and compile caches id: warm-caches continue-on-error: true @@ -219,15 +201,7 @@ jobs: # Only the publisher prunes restored entries before saving a new seed. OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER: "1" OPENCLAW_NODE_COMPILE_CACHE_WRITER: "1" - # Keep compiled imports and per-file cleanup in the normal runner; - # ordinary CI owns test-body validation. - OPENCLAW_NODE_TEST_VITEST_ARGS_JSON: '["--testNamePattern=(?!)"]' - run: | - if [[ "$CACHE_WARM_PLATFORM" == "linux-hosted" ]]; then - node --import tsx scripts/ci-warm-hosted-vitest-caches.mts - else - node --import tsx scripts/ci-run-node-test-shard.mts - fi + run: node --import tsx scripts/ci-warm-vitest-caches.mts - name: Save Vitest transform cache if: ${{ steps.setup-node-env.outputs.cache-mode == 'read-write' && steps.setup-node-env.outputs.vitest-cache-key != '' }} diff --git a/docs/ci.md b/docs/ci.md index 3416cd25de1b..09bccc92b026 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -37,7 +37,7 @@ Control UI CI installs the Chromium revision pinned by Playwright even when the Browser extension CI launches the installed, patched Chrome MCP dependency directly. -Build, QA and test orchestration restore the same [protected Node compile cache](/ci/scope-and-routing/node-test-lanes). The trusted warmer populates build tools before collecting test imports; ordinary CI remains restore-only. +Build, QA and test orchestration restore the same [protected Node compile cache](/ci/scope-and-routing/node-test-lanes). The trusted warmer populates build tools before collecting test imports, including the same seven Control UI seed files on Node and the pinned Bun fork in both Linux cache backends; ordinary CI remains restore-only. In-process Gateway test configs use [exclusive plan admission within existing packed jobs](/ci/capacity#measured-shard-weights). @@ -53,7 +53,12 @@ Source-only Linux Node shards can reuse content-validated compiled workers from Vitest transform-cache fingerprints exclude the generated `.ci-harness` checkout so CI consumers and the protected warmer hash the same source inputs. Node bytecode caching remains enabled for ordinary Vitest runs; Vitest owns the worker-level coverage safeguard described in [local testing](/reference/test/local#core-commands). -Linux PR tests use Bun for the measured compatible lanes. Full Release Validation +Transform keys also include each project's dependency optimizer directory. This +prevents cached UI imports from mixing separate projects' Lit instances when a +focused run and a full run share the persistent cache. + +Linux PR tests use Bun for the measured compatible unit lanes and Control UI +Vitest job, with a targeted CSS-tokenizer optimizer workaround. Full Release Validation keeps their Node coverage and runs them on Bun too; see [test runtime selection](/ci/pipeline#test-runtime-selection). Auto-reply reply tests run files in parallel with two workers per compact group. Their planner uses separate parallel timing identities; until those have measurements, serial group costs are divided by the effective worker count, with single-file groups retaining their full cost. diff --git a/docs/ci/pipeline.md b/docs/ci/pipeline.md index bf0bab2285f6..a4d098abaa5c 100644 --- a/docs/ci/pipeline.md +++ b/docs/ci/pipeline.md @@ -115,8 +115,11 @@ Linux test shards select Bun through `scripts/lib/ci-test-runtime.mts`. The ordinary and isolated unit-fast lanes partition their existing file inventories: files with known Bun failures or additional skips stay on Node, and the compatible remainder runs on Bun. Those Node files still execute; they are not excluded from CI. The complete -fake-timer lane also supports Bun. UI and other families retain Node until they -pass on the pinned fork within their existing CI resource budgets. Precise PR targets use the existing +fake-timer lane also supports Bun. Control UI retains two whole GC-sensitive +files on Node (`chat-pane-retained-presentation.test.ts` and +`usage-page-details.test.ts`) and runs the remaining files on Bun. +Other families retain Node until they pass on the pinned fork within their +existing CI resource budgets. Precise PR targets use the existing test-project planner to find their owners. Mixed or ambiguous selections retain Node, and no tests are removed from the selected inventory. @@ -125,6 +128,30 @@ Ordinary manual CI, including Full Release Validation's `normal_ci` child, runs the complete original selection on Node and its compatible portion on Bun within the same job and worker slot. Other selections run on Node. Main pushes retain Node. Historical targets without the runtime-selection capability keep their original Node behavior. +The UI job probes its actual config and arguments through the target's runtime +owner, so older unit-only helpers, helpers requiring the retired global FTL flag, +and legacy compatibility targets retain Node. +Its three native shards and three workers per row remain unchanged. +The UI runtime partition is applied after Vitest selects each native shard, so +files keep their original shard ownership. A shard with no Node-only files +finishes that partition without running other UI files. Dual validation runs +the complete UI selection on Node, then excludes only those two files from Bun; +their assertions remain required on Node, with no added skips. + +The nonbrowser Control UI projects load `bun-css-tokenizer.setup.ts`. On Bun, +this setup resolves jsdom's native CSS tokenizer and prevents inlining only its +`endOfFile` predicate. The pinned fork can otherwise +enter an unbounded CSS-tokenizer loop after an ordered sequence of UI files. +Baseline, DFG, and FTL JIT remain enabled; Node and Chromium are unaffected. +The UI runtime owner delays FTL compilation with warmup/soon thresholds of +512000/8000. These short-lived workers benefit from less compilation work; +the protected cache publisher uses the same policy when collecting its seven +canonical UI seed files on Bun. PR jobs restore that Bun seed alongside the +Node seed, with separate transform-cache leaves. +The setup leaves tokenizer exports and CSS behavior unchanged. Remove it +only after a corrected pinned runtime passes the original ordered reproduction, +the complete UI config, and all three native shards within their existing memory +budgets. The test-runtime setup action installs a checksum-pinned build of the Bun fork only for jobs that need it. The source commit, archive checksum, and executable @@ -134,8 +161,8 @@ Vitest and its workers use the selected runtime. Bun and Node have separate transform-cache directories and timing identities. Either runtime failing fails the job. This adds no matrix rows or runner registrations. -`NODE_OPTIONS` continues to limit Node heaps; Bun does not use that V8 heap -limit. Compare observed memory use alongside elapsed time before admitting more +`NODE_OPTIONS`, where configured, limits Node heaps; the UI lane retains Node's +default heap limit. Bun does not use that V8 limit. Compare observed memory use alongside elapsed time before admitting more lanes. Compatibility evidence must use the exact fork build installed by CI; stock Bun results and different fork revisions are separate measurements. diff --git a/docs/ci/release-validation/full-release-validation.md b/docs/ci/release-validation/full-release-validation.md index 236a211e8bbd..1fe1ac3e294d 100644 --- a/docs/ci/release-validation/full-release-validation.md +++ b/docs/ci/release-validation/full-release-validation.md @@ -37,6 +37,10 @@ For targets with [test runtime selection](/ci/pipeline#test-runtime-selection), Bun-compatible selection on Bun. Both results are required; they share existing jobs and execute sequentially within each worker slot. Older targets without this capability retain Node-only testing. +This includes the Control UI config when the target's runtime owner admits it; +the targeted CSS-tokenizer optimizer workaround applies to its Bun pass, which excludes two +GC-sensitive files retained in the full Node pass. An older unit-only runtime +owner retains the UI's Node pass. Package Acceptance separately retains expanded published-upgrade scenarios: current unpublished candidates include native operator state, and stable/full diff --git a/docs/ci/scope-and-routing/node-test-lanes.md b/docs/ci/scope-and-routing/node-test-lanes.md index 029460f77649..c38b6a7c6b40 100644 --- a/docs/ci/scope-and-routing/node-test-lanes.md +++ b/docs/ci/scope-and-routing/node-test-lanes.md @@ -33,7 +33,7 @@ The slowest Node test families are split or balanced so each job stays small wit - Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. - The browser-extension Chromium bootstrap command prepares `qaRuntime`. It builds the native-host and relay JavaScript and runtime assets; the separate artifact job owns Control UI and plugin SDK declaration validation. The real Chromium flow and its assertions are unchanged. - The browser native-host launch test is a separate POSIX E2E case. Linux `build-artifacts` runs it explicitly after building or restoring dist, using `OPENCLAW_E2E_USE_PREBUILT_DIST=1` so the test cannot start another build. Its JSON report must contain exactly the named passing assertion in the expected file, with one passed test and zero failures, pending tests, or todos; missing artifacts, skipped tests, and absent results fail. The workflow step skips only when a frozen historical checkout lacks the test file: that is unavailable historical proof, not coverage. Current checkouts with a missing file still fail. Changes to the case, its installation fixture, or its relay-key fixture select the artifact job even on test-only diffs; unrelated browser unit tests stay build-free. Manual CI uses the same artifact step, independently of the release-only plugin sweep. -- Linux Node shard jobs persist Vitest's filesystem module cache through the upstream Actions cache API. On Blacksmith runners, official cache actions use [Blacksmith's colocated cache backend](https://docs.blacksmith.sh/blacksmith-caching/dependencies-actions) instead of GitHub's, so cache entries are backend-local even when their keys match. Blacksmith CI shards are restore-only and unpack the protected Blacksmith seed into isolated runner-local roots. While the GitHub-hosted outage backend is active, every `checks-node-*` test shard, `checks-ui`, the ordinary sharded `checks-ui-e2e` job, both fast contract matrices, and the Vitest-running `checks-fast-core` tasks restore a separately published immutable transform seed from GitHub's backend. The composite action's single default-off `restore-test-caches` input keeps the expansion easy to disable without changing cache keys or writer policy; mixed fast-core rows enable it only for tasks that invoke Vitest. The real-Gateway UI job does not restore these test caches; native and Control UI i18n lanes do not invoke Vitest. The hybrid planner profile uses matching key contracts across two backend-local archives: attempt-1 Blacksmith rows read the Blacksmith seed, while hosted retries can read only a separately published GitHub seed. Ordinary CI jobs remain restore-only; the separate trusted warmer owns protected backend-local cache publication. The non-cancelling warmer follows main, skips docs-only pushes, runs daily, and accepts manual or repository dispatch on main. Dependency publication and code warming serialize independently per backend, platform, and ref, so a new dependency seed can publish while a previous build is still running. Compiled Vitest workers remain in the full Linux code job, prepared and saved before native SDK compilation changes resolution topology; dependency jobs do no compiler work. The ordinary Linux code row follows `OPENCLAW_CI_RUNNER_BACKEND`. Hybrid mode also publishes a bounded hosted seed using twelve files across the real CI-routing, plugin/channel contract, and UI package configs. It runs the same direct pnpm entrypoints and project concurrency as hosted consumers; the UI keeps the shard runner. Each entrypoint warms its own writable cache leaves rather than copying an archive into other schedules. It omits the full build and broad agent/runtime collection; imports reached only by other files or test bodies can remain cold. A successful Blacksmith publication does not populate GitHub's cache backend. The Linux row launches each selected shard/config envelope through the normal runner in a fresh child process with concurrency one and `--testNamePattern=(?!)`. Collection preserves the include patterns, environment, compiled imports and per-file cleanup while reusing the same serial cache leaf. Test bodies run in ordinary CI; imports reached only inside those bodies can remain cold until that run. The warmer finishes every selected envelope and saves the content-keyed transform and compile caches even when collection fails, then reports the failure after the cache saves; ordinary CI shard execution remains fail-fast. This prevents config-global state from leaking, avoids expanding filtered shards into whole configs, and retains transforms produced by the previous child. Setup computes the transform-input fingerprint once when transform caching is enabled and passes it to restore and generation validation; disabled caches do not scan the checkout. The fingerprint clears incompatible lockfile, package, tsconfig, and Vitest-config generations. Before publishing, the trusted warmer scans and prunes the transform cache to 75% after it exceeds 2 GiB, and the Node compile cache to 75% after it exceeds 1 GiB. Consumer jobs never prune the restored seed. Vitest hashes module id, source content, environment, and resolved transform config, so ordinary partial source changes keep unchanged entries warm while changed modules miss safely. Coarse restore prefixes bridge workflow runs; normal Actions cache LRU and inactivity eviction bound old immutable archives. +- Linux Node shard jobs persist Vitest's filesystem module cache through the upstream Actions cache API. On Blacksmith runners, official cache actions use [Blacksmith's colocated cache backend](https://docs.blacksmith.sh/blacksmith-caching/dependencies-actions) instead of GitHub's, so cache entries are backend-local even when their keys match. Blacksmith CI shards are restore-only and unpack the protected Blacksmith seed into isolated runner-local roots. While the GitHub-hosted outage backend is active, every `checks-node-*` test shard, `checks-ui`, the ordinary sharded `checks-ui-e2e` job, both fast contract matrices, and the Vitest-running `checks-fast-core` tasks restore a separately published immutable transform seed from GitHub's backend. The composite action's single default-off `restore-test-caches` input keeps the expansion easy to disable without changing cache keys or writer policy; mixed fast-core rows enable it only for tasks that invoke Vitest. The real-Gateway UI job does not restore these test caches; native and Control UI i18n lanes do not invoke Vitest. The hybrid planner profile uses matching key contracts across two backend-local archives: attempt-1 Blacksmith rows read the Blacksmith seed, while hosted retries can read only a separately published GitHub seed. Ordinary CI jobs remain restore-only; the separate trusted warmer owns protected backend-local cache publication. The non-cancelling warmer follows main, skips docs-only pushes, runs daily, and accepts manual or repository dispatch on main. Dependency publication and code warming serialize independently per backend, platform, and ref, so a new dependency seed can publish while a previous build is still running. Compiled Vitest workers remain in the full Linux code job, prepared and saved before native SDK compilation changes resolution topology; dependency jobs do no compiler work. The ordinary Linux code row follows `OPENCLAW_CI_RUNNER_BACKEND`. Hybrid mode also publishes a bounded hosted seed using seventeen files across the real CI-routing, plugin/channel contract, and UI package configs. It runs the same direct pnpm entrypoints and project concurrency as hosted consumers; the Node UI keeps the shard runner. Both Linux code rows install the checksum-pinned Bun fork and collect the same seven UI seed files on Bun through the normal Vitest launcher. The shared shard environment owner places Bun transforms in its separate runtime cache leaf. Node and Bun UI collection use the ordinary UI heap environment, while heavier Node groups retain the 8 GiB ceiling. Each entrypoint warms its own writable cache leaves rather than copying an archive into other schedules. It omits the full build and broad agent/runtime collection; imports reached only by other files or test bodies can remain cold. A successful Blacksmith publication does not populate GitHub's cache backend. The Linux row launches each selected shard/config envelope through the normal runner in a fresh child process with concurrency one and `--testNamePattern=(?!)`. Collection preserves the include patterns, environment, compiled imports and per-file cleanup while reusing the same serial cache leaf. Test bodies run in ordinary CI; imports reached only inside those bodies can remain cold until that run. The warmer finishes every selected envelope and saves the content-keyed transform and compile caches even when collection fails, then reports the failure after the cache saves; ordinary CI shard execution remains fail-fast. This prevents config-global state from leaking, avoids expanding filtered shards into whole configs, and retains transforms produced by the previous child. Setup computes the transform-input fingerprint once when transform caching is enabled and passes it to restore and generation validation; disabled caches do not scan the checkout. The fingerprint clears incompatible lockfile, package, tsconfig, and Vitest-config generations. After both runtime producers join, the final Node UI collection owns the single pruning pass. Before publishing, the trusted warmer scans and prunes the combined transform cache to 75% after it exceeds 2 GiB, and the Node compile cache to 75% after it exceeds 1 GiB. Consumer jobs never prune the restored seed. Vitest hashes module id, source content, environment, and resolved transform config, so ordinary partial source changes keep unchanged entries warm while changed modules miss safely. Coarse restore prefixes bridge workflow runs; normal Actions cache LRU and inactivity eviction bound old immutable archives. - Trusted Blacksmith Linux Node jobs restore root `node_modules`, workspace importer trees (including plugin-local versions and links), and the workspace-local pnpm store from one immutable upstream Actions cache, which Blacksmith transparently serves from its colocated backend. Pnpm imports with hard links where the filesystem permits, and keeping the complete installed tree and store in one archive preserves those links. Pnpm's metadata cache lives beneath that same archived store root, so restored installs can verify supply-chain policy without depending on the producer's home directory. Source postinstall and build preparation leave pnpm-owned dependency trees intact. The key includes an explicit archive format, runner OS and architecture, the exact Node patch, and the semantic install-input fingerprint; there are no stale-prefix fallbacks. Frozen installs select tracked manifests from the dependency lockfile's importer and local-link graph, so unrelated release tools and test fixtures do not invalidate the archive. Mutable installs, custom pnpm hooks, local-file dependencies, and unsupported lockfile shapes retain the conservative tracked-manifest inventory; frozen pnpm reconciliation still validates every restore. Manifests are canonicalized before hashing. The repository-owned `openclaw` metadata block and non-install scripts are excluded because pnpm and the audited direct root hooks do not read them, so runtime schema, publication metadata, formatting, and ordinary test/build script edits keep the dependency tree warm; unaudited lifecycle-hook drift fails closed until its source inputs join the fingerprint contract. Dependency, package-manager, hook-source, and lockfile changes always select a new immutable archive. Every exact restore runs frozen offline pnpm reconciliation, so an unchanged archive validates without registry access or importer relinking. If reconciliation fails, setup first clears every importer tree and rebuilds it offline from the restored store, then clears both modules and store and retries from the network rather than serving a partial tree. Setup then disables pnpm's redundant pre-run dependency check so install and frozen reconciliation remain the only dependency writers; shard commands must not launch concurrent implicit installs. The separate trusted warmer publishes the toolchain and exact dependency archives immediately after setup succeeds, before build and transform warming; preflight and downstream CI jobs are restore-only. Canonical pushes and same-repo pull requests opt into exact restores only on actual self-hosted runners, including hybrid attempt 1. An exact miss automatically falls back to the coarser pnpm store cache. Manual CI dispatches, fork pull requests, hosted lanes, and hosted retries use only that store cache. Cache restore/save failures are optimization misses rather than correctness failures, and normal branch scoping, LRU, and inactivity eviction bound obsolete archives. The former mutable dependency StickyDisk path was retired after repeated successful writers acknowledged commits that later runs still restored as empty filesystems. - Node shard and build-artifact jobs also restore Node's portable on-disk compile cache through immutable Actions caches. In GitHub-hosted outage mode, the hosted Vitest lane set above restores the separately published GitHub test-scope archive alongside its transform seed. Build, QA and test orchestration share the protected seed populated by the trusted warmer's full build and test collection. The existing `test` cache namespace is retained for already-published archives; there is no separate build-only publisher. Ordinary `build-artifacts`, QA and test jobs only restore caches. PR and ordinary test jobs only read protected snapshots, so feature-branch bytecode never enters the shared seed and PR traffic creates no cache archives. This reuses V8 bytecode for Node-loaded orchestration, build tooling, and external dependencies across different checkout paths, including when only part of the source graph changes. A maximum-size 2 GiB transform archive costs roughly 15–20 seconds to restore at about 125 MB/s; measured fast-contract transforms are roughly 21 seconds against an approximately 8-second restore, and broader cold imports reach roughly 100–143 seconds. The optimization should be reverted if measured savings fall below restore cost. Ordinary Vitest runs preserve the configured Node compile cache. Vitest disables bytecode caching in workers and their child processes for V8 and custom coverage providers; explicit `NODE_DISABLE_COMPILE_CACHE=1` disables caching for the entire invocation. - Both Linux code-warming rows publish the native SDK declaration archive to their own backend before test collection; the full Linux row also publishes it before its build. Hosted lint stripes and the dedicated package-boundary lane restore the matching archive from their runner's backend and validate native compiler inputs and complete outputs before reusing it. Hybrid mode maintains that hosted seed without duplicating the full Blacksmith build. After saving, the warmer removes its native SDK output so the subsequent packaged declaration cache describes the same tree as ordinary build consumers. Package-store contents and pnpm store-location bookkeeping are not compiler inputs; installed dependency bytes, resolution topology, explicit inputs, and compiler identity still invalidate stale declarations. The all-Blacksmith profile retains its read-only sticky-disk path. The Control UI and UI E2E jobs share a Linux Playwright Chromium archive keyed by the exact pinned Playwright version. The independent dependency publisher also runs one standard hosted `macos-15` row that installs dependencies and publishes only the pnpm store after an exact miss. It uses the existing OS, architecture, Node-version, package, and lockfile key; macOS CI remains restore-only. This row leaves exact dependency, build, transform, and compile caching disabled and runs no build or test warming. Pnpm owns pruning, while the before/after disk usage and saved archive size expose retained content; pruning does not impose a content-store size bound. diff --git a/docs/reference/test/local.md b/docs/reference/test/local.md index a9005b91a675..61e30a5d5670 100644 --- a/docs/reference/test/local.md +++ b/docs/reference/test/local.md @@ -85,6 +85,25 @@ actual Vitest process and workers while retaining Node for orchestration and compiler preparation. It does not use Bun's native test runner. `bun run` alone does not select Bun for tests. Node remains the local default. +For the CI Control UI comparison, run the full Node selection followed by its +compatible Bun partition: + +```sh +OPENCLAW_NODE_TEST_CONFIGS_JSON='["ui/vitest.config.ts"]' \ +OPENCLAW_NODE_TEST_VITEST_ARGS_JSON='["--maxWorkers", "3"]' \ +OPENCLAW_CI_TEST_RUNTIME_POLICY=dual \ +node --import tsx scripts/ci-run-node-test-shard.mts +``` + +The pinned fork can loop in CSS tokenization after particular UI file orders. +The nonbrowser UI setup prevents inlining the native tokenizer's `endOfFile` +predicate on Bun while retaining baseline, DFG, and FTL JIT. It does nothing on Node, and +Chromium keeps its existing setup. See the +[CI runtime policy](/ci/pipeline#test-runtime-selection) for the removal proof. +The Bun partition deliberately excludes two whole GC-sensitive files, which +remain covered by Node. Running the complete UI config directly with +`OPENCLAW_VITEST_RUNTIME=bun` also runs those currently incompatible assertions. + Test processes and their CLI fixtures keep Sparkplug baseline compilation enabled but run it synchronously. This avoids a Node 24 shutdown deadlock where a background compiler waits for main-thread garbage collection while `process.exit` diff --git a/scripts/ci-run-node-test-shard.mts b/scripts/ci-run-node-test-shard.mts index 5f460ec8c9c5..0b39056e7e65 100644 --- a/scripts/ci-run-node-test-shard.mts +++ b/scripts/ci-run-node-test-shard.mts @@ -597,6 +597,12 @@ export async function runShardPlans(plans: ShardPlan[], options: RunShardOptions cacheSlot, runtime, }); + if (selection.includeAfterShard) { + childEnv.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE = + childEnv.OPENCLAW_VITEST_INCLUDE_FILE; + delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE; + } + Object.assign(childEnv, selection.env); const timingKey = entry.kind === "group" ? (entry.timingKey ?? entry.name) : entry.name; const timingPrefix = runtime === "bun" ? "bun:" : selection.includePatterns ? "node-subset:" : ""; diff --git a/scripts/ci-warm-hosted-vitest-caches.mts b/scripts/ci-warm-hosted-vitest-caches.mts deleted file mode 100644 index de9959a217ec..000000000000 --- a/scripts/ci-warm-hosted-vitest-caches.mts +++ /dev/null @@ -1,78 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createVitestCacheWarmGroups } from "./lib/ci-node-test-plan.mts"; -import { runManagedCommand } from "./lib/managed-child-process.mts"; - -// These entrypoints choose different writable cache leaves. Collect through the -// consumers themselves so scheduler-owned leaves remain isolated and reusable. -const groups = createVitestCacheWarmGroups("hybrid-hosted"); -const scratch = mkdtempSync(join(tmpdir(), "openclaw-hosted-cache-warm-")); -const baseEnv: NodeJS.ProcessEnv = { - ...process.env, - OPENCLAW_NODE_TEST_VITEST_ARGS_JSON: '["--testNamePattern=(?!)"]', -}; -delete baseEnv.OPENCLAW_VITEST_INCLUDE_FILE; -let exitCode = 0; -let interrupted = false; -let completed = false; -const collect = async (bin: string, args: string[], env: NodeJS.ProcessEnv) => { - if (interrupted) { - return; - } - const code = await runManagedCommand({ - bin, - args, - env, - requireProcessTreeExit: true, - onSignal() { - interrupted = true; - }, - }); - exitCode ||= code; -}; - -try { - const tooling = groups.find((group) => group.shard_name === "cache-warm:hosted-tooling"); - if (!tooling?.includePatterns) { - throw new Error("Missing hosted CI-routing cache seed"); - } - await collect("pnpm", ["test", ...tooling.includePatterns, "--testNamePattern=(?!)"], { - ...baseEnv, - OPENCLAW_TEST_PROJECTS_PARALLEL: "3", - }); - for (const [script, prefix, concurrency] of [ - ["test:contracts:plugins", "cache-warm:hosted-contracts-plugin", "1"], - ["test:contracts:channels", "cache-warm:hosted-contracts-channel-", "4"], - ] as const) { - const includeFile = join(scratch, `${concurrency}.json`); - writeFileSync( - includeFile, - JSON.stringify( - groups - .filter((group) => group.shard_name.startsWith(prefix)) - .flatMap((group) => group.includePatterns ?? []), - ), - ); - await collect("pnpm", [script, "--testNamePattern=(?!)"], { - ...baseEnv, - OPENCLAW_TEST_PROJECTS_PARALLEL: concurrency, - OPENCLAW_VITEST_INCLUDE_FILE: includeFile, - }); - } - // UI uses the same shard runner in CI. Its final pruning also bounds the - // direct and scheduler-owned leaves collected above before publication. - await collect(process.execPath, ["--import", "tsx", "scripts/ci-run-node-test-shard.mts"], { - ...baseEnv, - OPENCLAW_NODE_TEST_GROUPS_JSON: JSON.stringify( - groups.filter((group) => group.shard_name === "cache-warm:ui-package"), - ), - OPENCLAW_NODE_TEST_PLAN_CONCURRENCY: "1", - }); - process.exitCode = exitCode; - completed = true; -} finally { - if (completed) { - rmSync(scratch, { recursive: true, force: true }); - } -} diff --git a/scripts/ci-warm-vitest-caches.mts b/scripts/ci-warm-vitest-caches.mts new file mode 100644 index 000000000000..36ca7e0c414a --- /dev/null +++ b/scripts/ci-warm-vitest-caches.mts @@ -0,0 +1,114 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildChildEnv } from "./ci-run-node-test-shard.mts"; +import { createVitestCacheWarmGroups } from "./lib/ci-node-test-plan.mts"; +import { BUN_UI_TEST_ENV } from "./lib/ci-test-runtime.mts"; +import { runManagedCommand } from "./lib/managed-child-process.mts"; + +// Consumer entrypoints choose their own reusable cache leaves; the planner owns +// membership for both backend-local protected seeds. +const hosted = process.env.CACHE_WARM_PLATFORM === "linux-hosted"; +const groups = createVitestCacheWarmGroups(hosted ? "hybrid-hosted" : "full"); +const ui = groups.find((group) => group.shard_name === "cache-warm:ui-package"); +if (!ui?.includePatterns) { + throw new Error("Missing UI cache seed"); +} +const scratch = mkdtempSync(join(tmpdir(), "openclaw-cache-warm-")); +const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + OPENCLAW_CI_TEST_RUNTIME_POLICY: "node", + OPENCLAW_NODE_TEST_VITEST_ARGS_JSON: '["--testNamePattern=(?!)"]', + OPENCLAW_NODE_TEST_PLAN_CONCURRENCY: "1", + OPENCLAW_NODE_TEST_PLAN_CONTINUE_ON_FAILURE: "1", + // The final Node UI collection prunes both roots after all producers join. + OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER: "0", + OPENCLAW_NODE_COMPILE_CACHE_WRITER: "0", +}; +delete baseEnv.OPENCLAW_VITEST_INCLUDE_FILE; +delete baseEnv.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE; +let exitCode = 0; +let interrupted = false; +let completed = false; +const collect = async (bin: string, args: string[], env: NodeJS.ProcessEnv) => { + if (interrupted) { + return; + } + const code = await runManagedCommand({ + bin, + args, + env, + requireProcessTreeExit: true, + onSignal() { + interrupted = true; + }, + }); + exitCode ||= code; +}; + +try { + if (hosted) { + const tooling = groups.find((group) => group.shard_name === "cache-warm:hosted-tooling"); + if (!tooling?.includePatterns) { + throw new Error("Missing hosted CI-routing cache seed"); + } + await collect("pnpm", ["test", ...tooling.includePatterns, "--testNamePattern=(?!)"], { + ...baseEnv, + OPENCLAW_TEST_PROJECTS_PARALLEL: "3", + }); + for (const [script, prefix, concurrency] of [ + ["test:contracts:plugins", "cache-warm:hosted-contracts-plugin", "1"], + ["test:contracts:channels", "cache-warm:hosted-contracts-channel-", "4"], + ] as const) { + const includeFile = join(scratch, `${concurrency}.json`); + writeFileSync( + includeFile, + JSON.stringify( + groups + .filter((group) => group.shard_name.startsWith(prefix)) + .flatMap((group) => group.includePatterns ?? []), + ), + ); + await collect("pnpm", [script, "--testNamePattern=(?!)"], { + ...baseEnv, + OPENCLAW_TEST_PROJECTS_PARALLEL: concurrency, + OPENCLAW_VITEST_INCLUDE_FILE: includeFile, + }); + } + } else { + await collect(process.execPath, ["--import", "tsx", "scripts/ci-run-node-test-shard.mts"], { + ...baseEnv, + OPENCLAW_NODE_TEST_GROUPS_JSON: JSON.stringify(groups.filter((group) => group !== ui)), + }); + } + + // Collection is intentionally a partial selection, so invoke the normal + // Vitest launcher directly instead of the CI runtime-admission policy. + const bunEnv = buildChildEnv( + { kind: "group", name: ui.shard_name, plan: ui }, + { ...baseEnv, ...BUN_UI_TEST_ENV, NODE_OPTIONS: undefined }, + scratch, + 0, + { runtime: "bun", cacheSlot: 0 }, + ); + bunEnv.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE = bunEnv.OPENCLAW_VITEST_INCLUDE_FILE; + delete bunEnv.OPENCLAW_VITEST_INCLUDE_FILE; + await collect( + process.execPath, + ["scripts/run-vitest.mjs", "run", "--config", ...ui.configs, "--testNamePattern=(?!)"], + bunEnv, + ); + await collect(process.execPath, ["--import", "tsx", "scripts/ci-run-node-test-shard.mts"], { + ...baseEnv, + NODE_OPTIONS: undefined, + OPENCLAW_NODE_TEST_GROUPS_JSON: JSON.stringify([ui]), + OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER: process.env.OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER, + OPENCLAW_NODE_COMPILE_CACHE_WRITER: process.env.OPENCLAW_NODE_COMPILE_CACHE_WRITER, + }); + process.exitCode = exitCode; + completed = true; +} finally { + if (completed) { + rmSync(scratch, { recursive: true, force: true }); + } +} diff --git a/scripts/lib/ci-node-test-plan.mts b/scripts/lib/ci-node-test-plan.mts index 22b0f526801c..a60fa770111b 100644 --- a/scripts/lib/ci-node-test-plan.mts +++ b/scripts/lib/ci-node-test-plan.mts @@ -2608,7 +2608,10 @@ export function createVitestCacheWarmGroups(profile: "full" | "hybrid-hosted" = configs: ["ui/vitest.config.ts"], env: { OPENCLAW_VITEST_MAX_WORKERS: "1" }, includePatterns: [ - "ui/src/components/app-sidebar.test.ts", + "ui/src/components/app-sidebar.catalog.test.ts", + "ui/src/components/app-sidebar.interactions.test.ts", + "ui/src/components/app-sidebar.people.test.ts", + "ui/src/components/app-sidebar.sessions.test.ts", "ui/src/pages/chat/chat-view.test.ts", "ui/src/pages/chat/chat-pane-lifecycle.test.ts", "ui/src/pages/usage/metrics.node.test.ts", diff --git a/scripts/lib/ci-test-runtime.mts b/scripts/lib/ci-test-runtime.mts index 592644545937..8d694520781b 100644 --- a/scripts/lib/ci-test-runtime.mts +++ b/scripts/lib/ci-test-runtime.mts @@ -1,10 +1,13 @@ +import { globSync } from "node:fs"; import { matchesVitestGlob } from "../../test/vitest/vitest.pattern-file.ts"; +import { controlUiE2eTestGlobs, controlUiTestGlobs } from "../../test/vitest/vitest.ui-paths.mjs"; import { getUnitFastIsolatedTestFiles, getUnitFastTestFiles, getUnitFastTimerTestFiles, } from "../../test/vitest/vitest.unit-fast-paths.mjs"; import { buildVitestRunPlans } from "../test-projects.test-support.mts"; +import { vitestOptionConsumesNextArg } from "./vitest-cli-mode.mts"; export type CiTestRuntimePolicy = "node" | "bun-compatible" | "dual"; type TestRuntime = "node" | "bun"; @@ -16,14 +19,26 @@ type TestSelection = { vitestArgs?: readonly string[]; }; type TestShard = TestSelection & { groups?: readonly TestSelection[] }; -export type CiTestRuntimeSelection = { runtime: TestRuntime; includePatterns?: string[] }; +export type CiTestRuntimeSelection = { + runtime: TestRuntime; + includePatterns?: string[]; + includeAfterShard?: true; + env?: Readonly>; +}; + +// Short-lived UI workers spend less time compiling their top JIT tier when it +// starts later. Keep every tier enabled and share the producer/consumer policy. +export const BUN_UI_TEST_ENV = { + BUN_JSC_thresholdForFTLOptimizeAfterWarmUp: "512000", + BUN_JSC_thresholdForFTLOptimizeSoon: "8000", +} as const; const bunCompatibleConfigs = new Set(["test/vitest/vitest.unit-fast-fake-timers.config.ts"]); // Bun fork 3ff0efc82217775e04094a1d4402d7c6932ecb24 failed or added skips in these files. // Keep every case on Node while the canonical inventories own all other membership. const runtimePartitions = new Map< string, - { files: () => string[]; nodeRequired: ReadonlySet } + { files: (cwd: string) => string[]; nodeRequired: ReadonlySet; includeAfterShard?: true } >([ [ "test/vitest/vitest.unit-fast.config.ts", @@ -52,10 +67,25 @@ const runtimePartitions = new Map< [ "test/vitest/vitest.unit-fast-isolated.config.ts", { - files: getUnitFastIsolatedTestFiles, + files: () => getUnitFastIsolatedTestFiles(), nodeRequired: new Set(["src/proxy-capture/proxy-server.test.ts"]), }, ], + [ + "ui/vitest.config.ts", + { + files: (cwd) => + globSync(controlUiTestGlobs, { cwd, exclude: controlUiE2eTestGlobs }) + .map((file) => file.replaceAll("\\", "/")) + .toSorted(), + // These whole files retain their GC assertions on Node; Bun runs every other UI file. + nodeRequired: new Set([ + "ui/src/pages/chat/chat-pane-retained-presentation.test.ts", + "ui/src/pages/usage/usage-page-details.test.ts", + ]), + includeAfterShard: true, + }, + ], ]); function unitFastFiles(): string[] { @@ -82,6 +112,39 @@ function supportsRuntimePartition(args: string[]): boolean { return args.every((arg) => /^--(?:maxWorkers|testTimeout|hookTimeout)=\d+$/u.test(arg)); } +function supportsUiRuntime(args: string[]): boolean { + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + const consumesNext = vitestOptionConsumesNextArg(arg, args[index + 1]); + const separator = arg.indexOf("="); + const option = separator < 0 ? arg : arg.slice(0, separator); + const value = consumesNext + ? args[++index] + : separator < 0 + ? undefined + : arg.slice(separator + 1); + if (/^--(?:maxWorkers|testTimeout|hookTimeout)$/u.test(option) && /^\d+$/u.test(value ?? "")) { + continue; + } + if (option === "--shard" && /^[1-9]\d*\/[1-9]\d*$/u.test(value ?? "")) { + const [shard, count] = value!.split("/").map(Number); + if (shard! <= count!) { + continue; + } + } + if ( + option === "--reporter" && + ["verbose", "github-actions", "./scripts/lib/vitest-resource-reporter.mts"].includes( + value ?? "", + ) + ) { + continue; + } + return false; + } + return true; +} + export function resolveCiTestRuntimePolicy( env: NodeJS.ProcessEnv = process.env, ): CiTestRuntimePolicy { @@ -104,13 +167,22 @@ export function resolveCiTestRuntimeSelections( if ( policy === "node" || selection.env?.OPENCLAW_VITEST_INCLUDE_FILE || - !args || - !supportsRuntimePartition(args) + selection.env?.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE || + !args ) { return node; } const completeBun = (): CiTestRuntimeSelection[] => policy === "dual" ? [{ runtime: "node" }, { runtime: "bun" }] : [{ runtime: "bun" }]; + const uiPartition = + selection.configs?.length === 1 && + selection.configs[0] === "ui/vitest.config.ts" && + !selection.targets?.length && + !selection.includePatterns && + supportsUiRuntime(args); + if (!uiPartition && !supportsRuntimePartition(args)) { + return node; + } if (selection.targets?.length) { // Preserve exact target argv and its native owner; broad targets can carry // multiple process/filter contracts and stay on Node. @@ -126,10 +198,14 @@ export function resolveCiTestRuntimeSelections( } const config = plans[0]!.config; const partition = runtimePartitions.get(config); - if (!partition || !plans.every((plan) => plan.config === config)) { + if ( + !partition || + partition.includeAfterShard || + !plans.every((plan) => plan.config === config) + ) { return node; } - const files = new Set(partition.files()); + const files = new Set(partition.files(cwd)); return selection.targets.every( (target) => files.has(target) && !partition.nodeRequired.has(target), ) @@ -144,11 +220,11 @@ export function resolveCiTestRuntimeSelections( return completeBun(); } const partition = runtimePartitions.get(config); - if (!partition) { + if (!partition || (partition.includeAfterShard && !uiPartition)) { return node; } const files = partition - .files() + .files(cwd) .filter( (file) => !selection.includePatterns || @@ -163,9 +239,20 @@ export function resolveCiTestRuntimeSelections( ...(policy === "dual" ? node : nodeFiles.length - ? [{ runtime: "node" as const, includePatterns: nodeFiles }] + ? [ + { + runtime: "node" as const, + includePatterns: nodeFiles, + ...(partition.includeAfterShard ? { includeAfterShard: true as const } : {}), + }, + ] : []), - { runtime: "bun", includePatterns: bunFiles }, + { + runtime: "bun", + includePatterns: bunFiles, + ...(partition.includeAfterShard ? { includeAfterShard: true } : {}), + ...(config === "ui/vitest.config.ts" ? { env: BUN_UI_TEST_ENV } : {}), + }, ]; } diff --git a/scripts/lib/vitest-shard-metadata.mts b/scripts/lib/vitest-shard-metadata.mts index 9c70c8759f0f..dcc4563f2905 100644 --- a/scripts/lib/vitest-shard-metadata.mts +++ b/scripts/lib/vitest-shard-metadata.mts @@ -260,7 +260,11 @@ const STRIPE_FILE_SECONDS_HINTS = new Map([ ["src/gateway/session-message-events.test.ts", 26], ["src/gateway/tool-resolution.test.ts", 43], ["test/scripts/test-projects-routing.test.ts", 21], - ["ui/src/components/app-sidebar.test.ts", 28], + // Single-worker Node file spans after splitting the sidebar's serial case groups. + ["ui/src/components/app-sidebar.catalog.test.ts", 4], + ["ui/src/components/app-sidebar.interactions.test.ts", 6], + ["ui/src/components/app-sidebar.people.test.ts", 13], + ["ui/src/components/app-sidebar.sessions.test.ts", 10], ["ui/src/pages/chat/chat-responsive.browser.test.ts", 30], // Focused cold proof is ~34s after right-sizing and concurrent crash phases. ["test/scripts/bench-sqlite-reliability.test.ts", 34], diff --git a/test/fixtures/vitest-ui-runtime-partition.mjs b/test/fixtures/vitest-ui-runtime-partition.mjs new file mode 100644 index 000000000000..f6a7d678c628 --- /dev/null +++ b/test/fixtures/vitest-ui-runtime-partition.mjs @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import path from "node:path"; +import { BaseSequencer, createVitest } from "vitest/node"; + +const [output, selectionsPath, includeFile] = process.argv.slice(2); +if (!output || !selectionsPath || !includeFile) { + throw new Error("Expected runtime partition report, selections, and include paths"); +} +const selections = JSON.parse(fs.readFileSync(selectionsPath, "utf8")); +process.env.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE = includeFile; +fs.writeFileSync(includeFile, "[]"); +const ctx = await createVitest({ + config: path.resolve("ui/vitest.config.ts"), + watch: false, + reporters: [], + configLoader: "runner", + api: false, +}); +try { + const emptyDiscoveryAllowed = Boolean(ctx.config.passWithNoTests); + const specifications = await ctx.globTestSpecifications(); + const paths = (files) => + files.map((file) => path.relative(process.cwd(), file.moduleId).replaceAll("\\", "/")).sort(); + const rows = []; + for (const index of [undefined, 1, 2, 3]) { + ctx.config.shard = index ? { index, count: 3 } : undefined; + const native = new BaseSequencer(ctx); + const original = index ? await native.shard(specifications) : specifications; + const selected = {}; + for (const [policy, partition] of Object.entries(selections)) { + selected[policy] = []; + for (const selection of partition) { + fs.writeFileSync( + includeFile, + JSON.stringify(selection.includePatterns ?? paths(specifications)), + ); + const sequencer = new ctx.config.sequence.sequencer(ctx); + const sharded = index ? await sequencer.shard(specifications) : specifications; + selected[policy].push({ + runtime: selection.runtime, + files: paths(await sequencer.sort(sharded)), + }); + } + } + rows.push({ index, original: paths(original), selected }); + } + // Exercise the registered pool boundary with no members in this runtime's shard. + fs.writeFileSync(includeFile, "[]"); + ctx.config.shard = { index: 1, count: 3 }; + const empty = await ctx.runTestSpecifications(specifications); + fs.writeFileSync( + output, + JSON.stringify({ + discovered: paths(specifications), + rows, + empty: { modules: empty.testModules.length, errors: empty.unhandledErrors.length }, + emptyDiscoveryAllowed, + }), + ); +} finally { + await ctx.close(); +} diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index dc910a34bb98..2e7f3240dc2a 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -1651,7 +1651,10 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { configs: ["ui/vitest.config.ts"], env: { OPENCLAW_VITEST_MAX_WORKERS: "1" }, includePatterns: [ - "ui/src/components/app-sidebar.test.ts", + "ui/src/components/app-sidebar.catalog.test.ts", + "ui/src/components/app-sidebar.interactions.test.ts", + "ui/src/components/app-sidebar.people.test.ts", + "ui/src/components/app-sidebar.sessions.test.ts", "ui/src/pages/chat/chat-view.test.ts", "ui/src/pages/chat/chat-pane-lifecycle.test.ts", "ui/src/pages/usage/metrics.node.test.ts", @@ -1666,7 +1669,7 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { expect(groups.every((group) => (group.includePatterns?.length ?? 0) > 0)).toBe(true); const files = groups.flatMap((group) => group.includePatterns ?? []); - expect(files).toHaveLength(14); + expect(files).toHaveLength(17); expect(files.every((file) => existsSync(file))).toBe(true); expect(buildPrerequisites.resolveVitestPretestBuildMode(groups)).toBeUndefined(); const tooling = expectDefined( diff --git a/test/scripts/ci-run-node-test-shard.test.ts b/test/scripts/ci-run-node-test-shard.test.ts index 81b17694293a..f10647d5852a 100644 --- a/test/scripts/ci-run-node-test-shard.test.ts +++ b/test/scripts/ci-run-node-test-shard.test.ts @@ -398,6 +398,56 @@ describe("scripts/ci-run-node-test-shard.mts", () => { ]); }); + it.each(["bun-compatible", "dual"] as const)( + "applies the measured UI JIT policy only to the Bun child under %s", + async (policy) => { + const seen: Array> = []; + await expect( + runShardPlans([{ kind: "group", name: "ui", plan: { configs: ["ui/vitest.config.ts"] } }], { + env: { OPENCLAW_CI_TEST_RUNTIME_POLICY: policy }, + scratchDir: makeScratchDir(), + runChild: async (_args, env) => { + seen.push({ + runtime: env.OPENCLAW_VITEST_RUNTIME, + warmup: env.BUN_JSC_thresholdForFTLOptimizeAfterWarmUp, + soon: env.BUN_JSC_thresholdForFTLOptimizeSoon, + ftlEnabled: env.BUN_JSC_useFTLJIT, + }); + return 0; + }, + }), + ).resolves.toBe(0); + expect(seen).toEqual([ + { runtime: "node", warmup: undefined, soon: undefined, ftlEnabled: undefined }, + { runtime: "bun", warmup: "512000", soon: "8000", ftlEnabled: undefined }, + ]); + }, + ); + + it.each([ + { vitestArgs: ["--root=another-root"] }, + { vitestArgs: ["--config", "another.config.ts"] }, + { vitestArgs: ["--pool=threads"] }, + { vitestArgs: ["--watch"] }, + { vitestArgs: ["--shard=4/3"] }, + { vitestArgs: ["--reporter=custom.mts"] }, + { vitestArgs: ["--maxWorkers"] }, + { includePatterns: ["ui/src/pages/skills/view.test.ts"] }, + { targets: ["ui/src/pages/skills/view.test.ts"] }, + { configs: [], targets: ["ui/src/pages/skills/view.test.ts"], env: {} }, + { configs: ["ui/vitest.config.ts", bunConfig] }, + { + env: { OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE: "external.json" }, + }, + ])("keeps unproven UI execution envelopes on Node: %s", (overrides) => { + const selection = { + configs: ["ui/vitest.config.ts"], + ...overrides, + }; + expect(resolveCiTestRuntimeSelections(selection, "dual")).toEqual([{ runtime: "node" }]); + expect(ciTestShardRequiresBun(selection, "bun-compatible")).toBe(false); + }); + it.each([ { shard: { configs: [bunConfig] }, expected: true }, { shard: { configs: [] }, expected: false }, diff --git a/test/scripts/ci-warm-vitest-caches.test.ts b/test/scripts/ci-warm-vitest-caches.test.ts new file mode 100644 index 000000000000..1a956e89ec82 --- /dev/null +++ b/test/scripts/ci-warm-vitest-caches.test.ts @@ -0,0 +1,169 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveShardPlans, runShardPlans } from "../../scripts/ci-run-node-test-shard.mts"; +import { createVitestCacheWarmGroups } from "../../scripts/lib/ci-node-test-plan.mts"; +import { BUN_UI_TEST_ENV } from "../../scripts/lib/ci-test-runtime.mts"; +import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +vi.mock(import("../../scripts/lib/managed-child-process.mts"), async (importOriginal) => ({ + ...(await importOriginal()), + runManagedCommand: vi.fn(), +})); + +vi.mock(import("../../scripts/lib/ci-node-test-plan.mts"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createVitestCacheWarmGroups: vi.fn((profile: "full" | "hybrid-hosted" = "full") => { + const hosted = actual.createVitestCacheWarmGroups("hybrid-hosted"); + // The planner suite owns full inventory. Two distinct Node envelopes are + // enough to prove this collector continues after a child failure. + return profile === "hybrid-hosted" + ? hosted + : hosted.filter( + (group, index) => index < 2 || group.shard_name === "cache-warm:ui-package", + ); + }), + }; +}); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const originalExitCode = process.exitCode; +afterEach(() => { + process.exitCode = originalExitCode; + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe("protected Vitest cache collection", () => { + it.each(["linux", "linux-hosted"])( + "collects both UI runtimes before final pruning and preserves an earlier failure (%s)", + async (platform) => { + const root = tempDirs.make("openclaw-cache-warm-test-"); + const cacheRoot = join(root, "transforms"); + const compileRoot = join(root, "compile"); + const groups = createVitestCacheWarmGroups( + platform === "linux-hosted" ? "hybrid-hosted" : "full", + ); + const ui = groups.find((group) => group.shard_name === "cache-warm:ui-package")!; + for (const [name, value] of Object.entries({ + CACHE_WARM_PLATFORM: platform, + NODE_OPTIONS: "--max-old-space-size=8192", + NODE_COMPILE_CACHE: compileRoot, + OPENCLAW_VITEST_FS_MODULE_CACHE_PATH: cacheRoot, + OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER: "1", + OPENCLAW_NODE_COMPILE_CACHE_WRITER: "1", + OPENCLAW_VITEST_INCLUDE_FILE: "inherited-selection.json", + OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE: "inherited-post-shard-selection.json", + OPENCLAW_VITEST_WORKER_CACHE: "0", + })) { + vi.stubEnv(name, value); + } + for (const name of [ + "OPENCLAW_NODE_TEST_TARGETS_JSON", + "OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64", + "OPENCLAW_NODE_TEST_ENV_JSON", + "OPENCLAW_VITEST_MAX_WORKERS", + ]) { + vi.stubEnv(name, undefined); + } + const invocations: Array<{ args: string[]; env: NodeJS.ProcessEnv }> = []; + const nodeGroups: string[][] = []; + const uiCollections: Array<{ + args: string[]; + env: NodeJS.ProcessEnv; + files: string[]; + }> = []; + const recordUi = (args: string[], env: NodeJS.ProcessEnv) => { + const includeFile = + env.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE ?? env.OPENCLAW_VITEST_INCLUDE_FILE; + expect(includeFile).toBeTruthy(); + uiCollections.push({ + args, + env, + files: JSON.parse(readFileSync(includeFile!, "utf8")), + }); + }; + vi.mocked(runManagedCommand).mockImplementation(async (command) => { + const { args = [], env = {} } = command; + invocations.push({ args, env }); + expect(command.requireProcessTreeExit).toBe(true); + if (args.includes("scripts/ci-run-node-test-shard.mts")) { + expect(env.OPENCLAW_NODE_TEST_PLAN_CONCURRENCY).toBe("1"); + return await runShardPlans(resolveShardPlans(env), { + env, + continueOnFailure: env.OPENCLAW_NODE_TEST_PLAN_CONTINUE_ON_FAILURE === "1", + scratchDir: root, + runChild: async (childArgs, childEnv) => { + expect(childArgs).toContain("--testNamePattern=(?!)"); + if (childArgs.includes("ui/vitest.config.ts")) { + recordUi(childArgs, childEnv); + } else { + nodeGroups.push(childArgs.slice(0, childArgs.indexOf("--"))); + expect(childEnv.NODE_OPTIONS).toBe("--max-old-space-size=8192"); + return nodeGroups.length === 1 ? 19 : 0; + } + return 0; + }, + }); + } else if (args.includes("scripts/run-vitest.mjs")) { + recordUi(args, env); + } else { + expect(args).toContain("--testNamePattern=(?!)"); + expect(env.NODE_OPTIONS).toBe("--max-old-space-size=8192"); + } + return invocations.length === 1 ? 19 : 0; + }); + vi.resetModules(); + await import("../../scripts/ci-warm-vitest-caches.mts"); + + expect(createVitestCacheWarmGroups).toHaveBeenLastCalledWith( + platform === "linux-hosted" ? "hybrid-hosted" : "full", + ); + expect(process.exitCode).toBe(19); + expect(uiCollections.map(({ env }) => env.OPENCLAW_VITEST_RUNTIME)).toEqual(["bun", "node"]); + for (const collection of uiCollections) { + expect(collection.files).toEqual(ui.includePatterns); + expect(collection.args).toContain("--testNamePattern=(?!)"); + expect(collection.env.OPENCLAW_VITEST_MAX_WORKERS).toBe("1"); + expect(collection.env.NODE_OPTIONS).toBeUndefined(); + expect(collection.env.NODE_COMPILE_CACHE).toBe(compileRoot); + } + const [bun, node] = uiCollections; + expect(bun!.env).toMatchObject(BUN_UI_TEST_ENV); + expect(bun!.env.OPENCLAW_VITEST_INCLUDE_FILE).toBeUndefined(); + expect(bun!.env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toBe( + join(cacheRoot, "vitest-cache-bun-0"), + ); + expect(node!.env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH).toBe( + join(cacheRoot, "vitest-cache-0"), + ); + expect(existsSync(bun!.env.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE!)).toBe(false); + expect(invocations.at(-1)!.args).toContain("scripts/ci-run-node-test-shard.mts"); + expect( + invocations.map(({ env }) => [ + env.OPENCLAW_VITEST_FS_MODULE_CACHE_WRITER, + env.OPENCLAW_NODE_COMPILE_CACHE_WRITER, + ]), + ).toEqual([...invocations.slice(0, -1).map(() => ["0", "0"]), ["1", "1"]]); + if (platform === "linux") { + const nonUiGroups = groups.filter((group) => group !== ui); + expect(JSON.parse(invocations[0]!.env.OPENCLAW_NODE_TEST_GROUPS_JSON!)).toEqual( + nonUiGroups, + ); + expect(nodeGroups).toEqual(nonUiGroups.map((group) => group.configs)); + } else { + expect(invocations.slice(0, 3).map(({ args }) => args[0])).toEqual([ + "test", + "test:contracts:plugins", + "test:contracts:channels", + ]); + expect( + invocations.slice(0, 3).map(({ env }) => env.OPENCLAW_TEST_PROJECTS_PARALLEL), + ).toEqual(["3", "1", "4"]); + } + }, + ); +}); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 36ea984af8be..7896851b2917 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -5561,8 +5561,8 @@ server.listen(0, "127.0.0.1", () => { const checkoutStep = warmer.jobs.warm.steps.find( (step: WorkflowStep) => step.name === "Checkout", ); - const seedStep = warmer.jobs.warm.steps.find( - (step: WorkflowStep) => step.name === "Select cache seed", + const bunSetup = warmer.jobs.warm.steps.find( + (step: WorkflowStep) => step.name === "Setup pinned Bun test runtime", ); const warmStep = warmer.jobs.warm.steps.find( (step: WorkflowStep) => step.name === "Warm transform and compile caches", @@ -5653,10 +5653,10 @@ server.listen(0, "127.0.0.1", () => { "vitest-fs-cache": "true", "vitest-worker-cache": String(full), }); - for (const step of [buildStep, boundaryCleanupStep, seedStep]) { + for (const step of [buildStep, boundaryCleanupStep]) { expect(evaluateWorkflowExpression(step.if, context), step.name).toBe(full); } - for (const step of [boundaryPrepareStep, warmStep]) { + for (const step of [boundaryPrepareStep, bunSetup, warmStep]) { expect(step.if, step.name).toBeUndefined(); } expect(evaluateWorkflowExpression(warmAssertionStep.if, context)).toBe(true); @@ -5666,31 +5666,27 @@ server.listen(0, "127.0.0.1", () => { } } } - for (const [platform, collector] of [ - ["linux", "ci-run-node-test-shard"], - ["linux-hosted", "ci-warm-hosted-vitest-caches"], - ]) { + expect(bunSetup.uses).toBe("./.github/actions/setup-test-bun"); + expect(warmerSteps.indexOf(bunSetup)).toBeLessThan(warmerSteps.indexOf(warmStep)); + expect( + warmer.jobs.dependencies.steps.some( + (step: WorkflowStep) => step.uses === "./.github/actions/setup-test-bun", + ), + ).toBe(false); + for (const platform of ["linux", "linux-hosted"]) { const invocation = runWorkflowShellScript( `node() { printf '%s\\n' "$*"; return 23; }\n${warmStep.run}`, { env: { ...process.env, CACHE_WARM_PLATFORM: platform } }, ); expect(invocation.stdout.trim(), invocation.stderr).toBe( - `--import tsx scripts/${collector}.mts`, + "--import tsx scripts/ci-warm-vitest-caches.mts", ); expect(invocation.status, invocation.stderr).toBe(23); } expect(warmer.on).not.toHaveProperty("workflow_run"); expect(checkoutStep.with).toBeUndefined(); expect(warmerSource).toContain('cron: "17 8 * * *"'); - expect(seedStep.run).toContain( - 'import { createVitestCacheWarmGroups } from "./scripts/lib/ci-node-test-plan.mts";', - ); - expect(seedStep.run).toMatch( - /const groups = createVitestCacheWarmGroups\(\);[\s\S]*appendFileSync\(\s*process\.env\.GITHUB_ENV,[\s\S]*OPENCLAW_NODE_TEST_GROUPS_JSON=\$\{JSON\.stringify\(groups\)\}/u, - ); expect(warmerSource).not.toContain("OPENCLAW_NODE_TEST_CONFIGS_JSON"); - expect(warmerSource).toContain('"OPENCLAW_NODE_TEST_PLAN_CONCURRENCY=1"'); - expect(seedStep.run).toContain('"OPENCLAW_NODE_TEST_PLAN_CONTINUE_ON_FAILURE=1"'); expect(warmStep.id).toBe("warm-caches"); expect(warmStep["continue-on-error"]).toBe(true); expect(warmStep.env).toMatchObject({ @@ -5736,7 +5732,7 @@ server.listen(0, "127.0.0.1", () => { warmerSteps.findIndex((step) => step.name === "Warm build cache"), ); expect(warmerSteps.indexOf(saveStep), saveStep.name).toBeLessThan( - warmerSteps.indexOf(seedStep), + warmerSteps.indexOf(warmStep), ); expect(saveStep.if).not.toMatch(/always\(|failure\(/u); } else if (saveStep.name === "Save native SDK boundary cache") { @@ -5760,7 +5756,6 @@ server.listen(0, "127.0.0.1", () => { // No close-time cleanup workflow is needed; Actions cache LRU/TTL expires // old hosted-writer and warmer generations. expect(existsSync(".github/workflows/pr-cache-cleanup.yml")).toBe(false); - expect(seedStep.if).toBe("${{ matrix.platform == 'linux' }}"); expect(warmStep.if).toBeUndefined(); const distSave = expectDefined( saveSteps.find((step) => step.name === "Save dist build cache"), @@ -8668,11 +8663,46 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" ); it.each([ - { label: "current", frozenTarget: false, compatibilityTarget: false, shards: [1, 2, 3] }, - { label: "frozen current", frozenTarget: true, compatibilityTarget: false, shards: [1] }, - { label: "frozen legacy", frozenTarget: true, compatibilityTarget: true, shards: [1] }, + { + label: "current", + frozenTarget: false, + compatibilityTarget: false, + policy: "bun-compatible", + runtimes: ["node", "bun"], + shards: [1, 2, 3], + }, + { + label: "frozen current", + frozenTarget: true, + compatibilityTarget: false, + policy: "dual", + runtimes: ["node", "bun"], + shards: [1], + }, + { + label: "frozen legacy", + frozenTarget: true, + compatibilityTarget: true, + policy: "node", + runtimes: ["node"], + shards: [1], + }, ])("executes the $label standalone UI envelope", async (scenario) => { const workflow = readCiWorkflow(); + expect(workflow.env?.BUN_JSC_useFTLJIT).toBeUndefined(); + const ftlSteps: string[] = []; + for (const [name, job] of Object.entries<{ + env?: Record; + steps?: WorkflowStep[]; + }>(workflow.jobs)) { + expect(job.env?.BUN_JSC_useFTLJIT).toBeUndefined(); + for (const step of job.steps ?? []) { + if (step.env?.BUN_JSC_useFTLJIT !== undefined) { + ftlSteps.push(`${name}/${step.name}`); + } + } + } + expect(ftlSteps).toEqual([]); const ui = workflow.jobs["checks-ui"]; const lint = ui.steps.find( (step: WorkflowStep) => step.name === "Lint Control UI window.open usage", @@ -8696,7 +8726,10 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" const context = { eventName: scenario.frozenTarget ? "workflow_dispatch" : "pull_request", frozenTarget: scenario.frozenTarget, - preflightOutputs: { compatibility_target: String(scenario.compatibilityTarget) }, + preflightOutputs: { + compatibility_target: String(scenario.compatibilityTarget), + ui_test_runtime_policy: scenario.policy, + }, repository: "openclaw/openclaw", runAttempt: 1, runnerBackend: "hybrid", @@ -8749,6 +8782,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" ]), ); expect(env.OPENCLAW_NODE_TEST_PLAN_CONCURRENCY).toBe("1"); + expect(env.BUN_JSC_useFTLJIT).toBeUndefined(); expect(env.OPENCLAW_UI_E2E_DIAGNOSTIC_DIR).toBe( `${root}/.artifacts/control-ui-e2e-timeouts/ui-shard-${shard}-attempt-1`, ); @@ -8781,6 +8815,7 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" env.OPENCLAW_NODE_TEST_VITEST_ARGS_JSON = readFileSync(argsPath, "utf8"); expect(JSON.parse(env.OPENCLAW_NODE_TEST_VITEST_ARGS_JSON)).toEqual(flags); const forwarded: string[][] = []; + const runtimes: Array = []; expect( await runShardPlans(resolveShardPlans(env), { concurrency: Number(env.OPENCLAW_NODE_TEST_PLAN_CONCURRENCY), @@ -8788,6 +8823,29 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" scratchDir: root, runChild: async (args, childEnv) => { forwarded.push(args); + runtimes.push(childEnv.OPENCLAW_VITEST_RUNTIME); + expect(childEnv.BUN_JSC_useFTLJIT).toBeUndefined(); + expect(childEnv.OPENCLAW_VITEST_INCLUDE_FILE).toBeUndefined(); + const includeFile = childEnv.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE; + if ( + childEnv.OPENCLAW_VITEST_RUNTIME === "bun" || + scenario.policy === "bun-compatible" + ) { + expect(includeFile).toBeTruthy(); + const included = JSON.parse(readFileSync(includeFile!, "utf8")); + const nodeFiles = [ + "ui/src/pages/chat/chat-pane-retained-presentation.test.ts", + "ui/src/pages/usage/usage-page-details.test.ts", + ]; + if (childEnv.OPENCLAW_VITEST_RUNTIME === "node") { + expect(included.toSorted()).toEqual(nodeFiles); + } else { + expect(included.length).toBeGreaterThan(1000); + expect(included.filter((file: string) => nodeFiles.includes(file))).toEqual([]); + } + } else { + expect(includeFile).toBeUndefined(); + } expect(childEnv.OPENCLAW_TEST_PROJECTS_PARALLEL).toBe("1"); expect(childEnv.OPENCLAW_UI_E2E_DIAGNOSTIC_DIR).toBe( resolveValue(test.env.OPENCLAW_UI_E2E_DIAGNOSTIC_DIR), @@ -8796,7 +8854,10 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" }, }), ).toBe(0); - expect(forwarded).toEqual([["ui/vitest.config.ts", "--", ...flags]]); + expect(runtimes).toEqual(scenario.runtimes); + expect(forwarded).toEqual( + scenario.runtimes.map(() => ["ui/vitest.config.ts", "--", ...flags]), + ); } } const calls = readFileSync(callsPath, "utf8").trim().split("\n"); diff --git a/test/scripts/ci-workflow-planning.test.ts b/test/scripts/ci-workflow-planning.test.ts index 4df3c3cf2eda..54f7534f7999 100644 --- a/test/scripts/ci-workflow-planning.test.ts +++ b/test/scripts/ci-workflow-planning.test.ts @@ -174,6 +174,7 @@ function runCiManifestFixture(options: { nodeTestShards?: Record[]; nodeTestGroupsCodec?: boolean; bunTestRuntime?: boolean; + bunUiTestRuntime?: boolean | "requires-ftl-flag"; startupCorpusCoverage?: boolean; changedPlannerSource?: string | null; changedPlannerDependencies?: string[]; @@ -216,8 +217,11 @@ function runCiManifestFixture(options: { if (options.bunTestRuntime) { writeFileSync( path.join(scriptsDir, "ci-test-runtime.mts"), - `export const ciTestShardRequiresBun = (shard, policy) => - policy !== "node" && shard.configs?.includes("fixture-bun.config.ts");`, + `${options.bunUiTestRuntime ? `import { ciTestShardRequiresBun as currentRuntime } from ${JSON.stringify(pathToFileURL(path.resolve("scripts/lib/ci-test-runtime.mts")).href)};` : ""} + export const ciTestShardRequiresBun = (shard, policy) => + policy !== "node" && (shard.configs?.includes("fixture-bun.config.ts") || + ${options.bunUiTestRuntime === "requires-ftl-flag" ? 'shard.env?.BUN_JSC_useFTLJIT === "false" &&' : ""} + ${options.bunUiTestRuntime ? `currentRuntime(shard, policy, ${JSON.stringify(process.cwd())})` : "false"});`, ); } for (const dependency of options.changedPlannerDependencies ?? []) { @@ -4218,16 +4222,82 @@ describe("ci workflow guards", () => { }); it.each([ - { eventName: "pull_request", capability: true, policy: "bun-compatible", bun: true }, - { eventName: "workflow_dispatch", capability: true, policy: "dual", bun: true }, - { eventName: "push", capability: true, policy: "node", bun: false }, - { eventName: "workflow_dispatch", capability: false, policy: "node", bun: false }, + { + eventName: "pull_request", + capability: true, + uiCapability: true, + policy: "bun-compatible", + uiPolicy: "bun-compatible", + bun: true, + }, + { + eventName: "workflow_dispatch", + capability: true, + uiCapability: true, + policy: "dual", + uiPolicy: "dual", + bun: true, + }, + { + eventName: "workflow_dispatch", + releaseGate: true, + capability: true, + uiCapability: true, + policy: "bun-compatible", + uiPolicy: "bun-compatible", + bun: true, + }, + { + eventName: "push", + capability: true, + uiCapability: true, + policy: "node", + uiPolicy: "node", + bun: false, + }, + { + eventName: "workflow_dispatch", + capability: false, + uiCapability: false, + policy: "node", + uiPolicy: "node", + bun: false, + }, + { + eventName: "pull_request", + capability: true, + uiCapability: false, + policy: "bun-compatible", + uiPolicy: "node", + bun: true, + }, + { + eventName: "workflow_dispatch", + capability: true, + uiCapability: "requires-ftl-flag", + policy: "dual", + uiPolicy: "node", + bun: true, + }, + { + eventName: "workflow_dispatch", + historicalCompatibility: true, + capability: true, + uiCapability: true, + policy: "dual", + uiPolicy: "node", + bun: true, + }, ] as const)( "routes test runtimes without adding jobs ($eventName, capability=$capability)", - ({ eventName, capability, policy, bun }) => { + (scenario) => { + const { eventName, capability, uiCapability, policy, uiPolicy, bun } = scenario; const manifest = runCiManifestFixture({ + historicalCompatibility: false, + ...scenario, bundledPlanner: true, bunTestRuntime: capability, + bunUiTestRuntime: uiCapability, eventName, nodeTestShards: [ { @@ -4246,6 +4316,7 @@ describe("ci workflow guards", () => { ).include; expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ test_runtime_policy: policy, requires_bun: bun }); + expect(manifest.outputs.ui_test_runtime_policy).toBe(uiPolicy); const job = readCiWorkflow().jobs["checks-node-core-test-nondist-shard"]; const context = { eventName, @@ -4264,6 +4335,19 @@ describe("ci workflow guards", () => { expect(evaluateWorkflowExpression(run.env.OPENCLAW_CI_TEST_RUNTIME_POLICY, context)).toBe( policy, ); + const ui = readCiWorkflow().jobs["checks-ui"]; + const uiContext = { ...context, preflightOutputs: manifest.outputs }; + const uiBunSetup = ui.steps.find( + (step: WorkflowStep) => step.name === "Setup pinned Bun test runtime", + ); + expect(uiBunSetup.uses).toBe("./.ci-harness/.github/actions/setup-test-bun"); + expect(evaluateWorkflowExpression(`\${{ ${uiBunSetup.if} }}`, uiContext)).toBe( + uiPolicy !== "node", + ); + const uiRun = ui.steps.find((step: WorkflowStep) => step.name === "Test Control UI"); + expect(evaluateWorkflowExpression(uiRun.env.OPENCLAW_CI_TEST_RUNTIME_POLICY, uiContext)).toBe( + uiPolicy, + ); }, ); diff --git a/test/vitest-performance-config.test.ts b/test/vitest-performance-config.test.ts index ca3d27bf063f..13261e3b8a04 100644 --- a/test/vitest-performance-config.test.ts +++ b/test/vitest-performance-config.test.ts @@ -173,6 +173,53 @@ ${source} `), ); + it("preserves each project's optimized dependency identity when reusing shared transforms", () => { + const { root } = prepareCacheFixture("optimizer-identity"); + const cacheOwner = new URL("./vitest/vitest.performance-config.ts", import.meta.url).href; + runCacheApi( + root, + ` +import { createVitestProjectCachePlugin } from ${JSON.stringify(cacheOwner)}; +fs.writeFileSync(path.join(root, "subject.js"), 'export const dependencyRoot = "__OPTIMIZER_ROOT__";'); +const transforms = []; +const plugin = { + name: "fixture-optimized-dependency-import", + transform(code, id) { + if (!id.endsWith("/subject.js")) return; + const directory = this.environment.config.cacheDir; + transforms.push(directory); + return { code: code.replace('"__OPTIMIZER_ROOT__"', JSON.stringify(directory)), map: null }; + }, +}; +const create = () => createVitest("test", { + root, config: false, watch: false, ...cacheConfig, + projects: ["A", "B"].map(name => ({ + extends: false, + root, + plugins: [plugin, createVitestProjectCachePlugin()], + test: { name, ...cacheConfig }, + })), +}); +for (let pass = 0; pass < 2; pass++) { + const ctx = await create(); + try { + const directories = []; + for (const name of ["A", "B"]) { + const project = ctx.getProjectByName(name); + const { dependencyRoot } = await project.import("./subject.js"); + assert.equal(dependencyRoot, project.vite.config.cacheDir, name + " must keep its own dependency imports"); + directories.push(dependencyRoot); + } + assert.notEqual(directories[0], directories[1]); + } finally { + await ctx.close(); + } +} +assert.equal(transforms.length, 2, "both projects must reuse their own persisted transforms on the second run"); +`, + ); + }); + it("preserves another checkout's cache when shared dependencies change", () => { const root = tempDirs.make("oc-vitest-cache-ownership-"); const sharedModules = path.join(root, "shared", "node_modules"); diff --git a/test/vitest-ui-package-config.test.ts b/test/vitest-ui-package-config.test.ts index aefabe035bd1..d56db4ccc5c1 100644 --- a/test/vitest-ui-package-config.test.ts +++ b/test/vitest-ui-package-config.test.ts @@ -3,6 +3,7 @@ import { globSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveCiTestRuntimeSelections } from "../scripts/lib/ci-test-runtime.mts"; import { buildVitestRunPlans } from "../scripts/test-projects.test-support.mts"; import uiConfig from "../ui/vitest.config.ts"; import uiNodeConfig from "../ui/vitest.node.config.ts"; @@ -27,6 +28,7 @@ type ExpectedTestConfig = ReturnType & { pool?: string; projects?: unknown[]; runner?: string; + setupFiles?: string[]; sequence?: { groupOrder?: number }; }; @@ -76,6 +78,68 @@ describe("ui package vitest config", () => { ]); }); + it("partitions runtimes after native UI sharding without changing ownership or dropping files", async ({ + signal, + }) => { + const root = tempDirs.make("ui-runtime-partition-"); + const output = path.join(root, "report.json"); + const selectionsPath = path.join(root, "selections.json"); + const selections = Object.fromEntries( + (["bun-compatible", "dual"] as const).map((policy) => [ + policy, + resolveCiTestRuntimeSelections({ configs: ["ui/vitest.config.ts"] }, policy), + ]), + ); + writeFileSync(selectionsPath, JSON.stringify(selections)); + const result = await runVitestShutdownCommand({ + args: [ + fileURLToPath(new URL("./fixtures/vitest-ui-runtime-partition.mjs", import.meta.url)), + output, + selectionsPath, + path.join(root, "include.json"), + ], + signal, + timeoutMs: DEFAULT_VITEST_TEST_TIMEOUT_MS, + env: { + PATH: process.env.PATH, + CI: "1", + OPENCLAW_VITEST_FS_MODULE_CACHE_PATH: path.join(root, "transforms"), + }, + }); + expect(result.code, result.stdout + result.stderr).toBe(0); + const report = JSON.parse(readFileSync(output, "utf8")) as { + discovered: string[]; + rows: Array<{ + original: string[]; + selected: Record>; + }>; + empty: { modules: number; errors: number }; + emptyDiscoveryAllowed: boolean; + }; + const nodeFiles = new Set([ + "ui/src/pages/chat/chat-pane-retained-presentation.test.ts", + "ui/src/pages/usage/usage-page-details.test.ts", + ]); + expect(report.discovered.length).toBeGreaterThan(1000); + expect(report.rows).toHaveLength(4); + expect(report.empty).toEqual({ modules: 0, errors: 0 }); + expect(report.emptyDiscoveryAllowed).toBe(false); + expect( + report.rows + .slice(1) + .flatMap((row) => row.original) + .toSorted(), + ).toEqual(report.discovered); + for (const row of report.rows) { + const compatible = row.selected["bun-compatible"]!; + expect(compatible.map((selection) => selection.runtime)).toEqual(["node", "bun"]); + expect(compatible[0]!.files).toEqual(row.original.filter((file) => nodeFiles.has(file))); + expect(compatible[1]!.files).toEqual(row.original.filter((file) => !nodeFiles.has(file))); + expect(compatible.flatMap((selection) => selection.files).toSorted()).toEqual(row.original); + expect(row.selected.dual).toEqual([{ runtime: "node", files: row.original }, compatible[1]]); + } + }); + it("gives module-mock fixtures the same isolated ownership in both entry points", async () => { vi.stubEnv("OPENCLAW_VITEST_INCLUDE_FILE", ""); vi.resetModules(); @@ -289,7 +353,7 @@ describe("ui package vitest config", () => { expect(selected.toSorted()).toEqual(expected); }); - it("keeps the standalone ui package on thread workers without broad isolation", () => { + it("keeps the standalone ui package on thread workers without broad isolation", async () => { const testConfig = requireTestConfig(uiConfig); expect(testConfig.pool).toBe("threads"); @@ -305,10 +369,19 @@ describe("ui package vitest config", () => { expect(projectTestConfig.pool).toBe("threads"); // Project overrides would defeat CI's explicit --maxWorkers limit. expect(projectTestConfig.maxWorkers).toBeUndefined(); + expect(projectTestConfig.setupFiles).toEqual( + projectTestConfig.browser?.enabled + ? ["./src/test-helpers/lit-warnings.setup.ts"] + : [ + "./src/test-helpers/bun-css-tokenizer.setup.ts", + "./src/test-helpers/lit-warnings.setup.ts", + ], + ); expect(projectTestConfig.isolate).toBe( projectTestConfig.name === "unit-mock-registry" || projectTestConfig.name === "unit-timing", ); } + await import("../ui/src/test-helpers/bun-css-tokenizer.setup.ts"); }); // The invariant, not a snapshot: `unit` shares one module graph and jsdom diff --git a/test/vitest/vitest.performance-config.ts b/test/vitest/vitest.performance-config.ts index e395f3ac6228..fe0c12747f8d 100644 --- a/test/vitest/vitest.performance-config.ts +++ b/test/vitest/vitest.performance-config.ts @@ -1,7 +1,21 @@ // Vitest performance config helper normalizes performance test environment settings. import path from "node:path"; +import type { Plugin } from "vite"; +import type { CacheKeyIdGenerator } from "vitest/node"; type EnvMap = Record; +const optimizerCacheKey: CacheKeyIdGenerator = ({ environment }) => environment.config.cacheDir; + +export function createVitestProjectCachePlugin(): Plugin { + return { + name: "openclaw:vitest-project-cache", + configureVitest({ defineCacheKeyGenerator }) { + // Cached imports embed project-specific optimized dependency paths. + defineCacheKeyGenerator(optimizerCacheKey); + }, + }; +} + const isEnabled = (value: string | undefined): boolean => { const normalized = value?.trim().toLowerCase(); return normalized === "1" || normalized === "true"; diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 3c8795273945..294d551b2688 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -16,7 +16,10 @@ import { BUNDLED_PLUGIN_ROOT_DIR, BUNDLED_PLUGIN_TEST_GLOB, } from "./vitest.bundled-plugin-paths.ts"; -import { loadVitestPerformanceConfig } from "./vitest.performance-config.ts"; +import { + createVitestProjectCachePlugin, + loadVitestPerformanceConfig, +} from "./vitest.performance-config.ts"; import { createRedactingReporterPlugin } from "./vitest.reporters.ts"; import { shouldPrintVitestThrottle } from "./vitest.system-load.ts"; import { DEFAULT_VITEST_TEST_TIMEOUT_MS } from "./vitest.timeouts.ts"; @@ -153,6 +156,7 @@ export const sharedVitestConfig = { }, createStateSchemaInlinePlugin(repoRoot), compiledSubprocessesPlugin(), + createVitestProjectCachePlugin(), createRedactingReporterPlugin(), ], resolve: { diff --git a/test/vitest/vitest.ui-runtime-sequencer.ts b/test/vitest/vitest.ui-runtime-sequencer.ts new file mode 100644 index 000000000000..8d8de82789f0 --- /dev/null +++ b/test/vitest/vitest.ui-runtime-sequencer.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { BaseSequencer, type TestSpecification } from "vitest/node"; +import { loadPatternListFromEnv } from "./vitest.pattern-file.ts"; + +const repoRoot = path.resolve(import.meta.dirname, "../.."); + +export class UiRuntimePartitionSequencer extends BaseSequencer { + override async sort(files: TestSpecification[]): Promise { + const included = loadPatternListFromEnv("OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE"); + const selected = included && new Set(included); + // Native shard() must see the complete inventory before runtime membership + // narrows it; filtering discovery would move unrelated files between CI rows. + const selectedFiles = selected + ? files.filter((file) => + selected.has(path.relative(repoRoot, file.moduleId).replaceAll("\\", "/")), + ) + : files; + if (selected && files.length > 0 && selectedFiles.length === 0) { + // This runtime has no members in a valid shard; missing discovery still fails. + this.ctx.config.passWithNoTests = true; + console.log("[ui-runtime] native shard has no files for this runtime partition"); + } + // eslint-disable-next-line unicorn/no-array-sort -- BaseSequencer.sort is Vitest's ordering API. + return super.sort(selectedFiles); + } +} diff --git a/ui/src/components/app-sidebar.catalog.test.ts b/ui/src/components/app-sidebar.catalog.test.ts new file mode 100644 index 000000000000..772e07ad523d --- /dev/null +++ b/ui/src/components/app-sidebar.catalog.test.ts @@ -0,0 +1,15 @@ +/* @vitest-environment jsdom */ + +import "../test-helpers/app-sidebar-suite.ts"; +import "../test-helpers/app-sidebar-cases/catalog-compat.ts"; +import "../test-helpers/app-sidebar-cases/catalog-live-events.ts"; +import "../test-helpers/app-sidebar-cases/catalog-project-activity.ts"; +import "../test-helpers/app-sidebar-cases/catalog-row-lifecycle.ts"; +import "../test-helpers/app-sidebar-cases/catalog-live.ts"; +import "../test-helpers/app-sidebar-cases/catalog-pagination-visibility.ts"; +import "../test-helpers/app-sidebar-cases/catalog-reconnect.ts"; +import "../test-helpers/app-sidebar-cases/catalog-live-errors.ts"; +import "../test-helpers/app-sidebar-cases/catalog-live-state.ts"; +import "../test-helpers/app-sidebar-cases/catalog-ownership.ts"; +import "../test-helpers/app-sidebar-cases/catalog-terminal-owner.ts"; +import "../test-helpers/app-sidebar-cases/catalog-pages.ts"; diff --git a/ui/src/components/app-sidebar.interactions.test.ts b/ui/src/components/app-sidebar.interactions.test.ts new file mode 100644 index 000000000000..0fe54d2e71b0 --- /dev/null +++ b/ui/src/components/app-sidebar.interactions.test.ts @@ -0,0 +1,36 @@ +/* @vitest-environment jsdom */ + +import { expect, it } from "vitest"; +import { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts"; +import "../test-helpers/app-sidebar-suite.ts"; +import "../test-helpers/app-sidebar-cases/basics.ts"; +import "../test-helpers/app-sidebar-cases/footer-status.ts"; +import "../test-helpers/app-sidebar-cases/group-mutations.ts"; +import "../test-helpers/app-sidebar-cases/interactions.ts"; +import "../test-helpers/app-sidebar-cases/new-group-dialog.ts"; +import "../test-helpers/app-sidebar-cases/section-reordering.ts"; +import "../test-helpers/app-sidebar-cases/session-delete-access.ts"; +import "../test-helpers/app-sidebar-cases/session-mutations.ts"; +import "../test-helpers/app-sidebar-cases/sidebar-scroll.ts"; +import "../test-helpers/app-sidebar-cases/transient-menus.ts"; + +it.each([0, 1])("resolves %i sidebar rows before agent selection is available", (count) => { + const sidebar = document.createElement("openclaw-app-sidebar"); + if (!(sidebar instanceof AppSidebarSessionNavigationElement)) { + throw new Error("expected the registered sidebar"); + } + const key = "agent:main:main"; + sidebar.sessionKey = key; + sidebar.sessionData.sessionsAgentId = "main"; + sidebar.sessionData.sessionsResult = { + ts: 1, + path: "", + count, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: count === 0 ? [] : [{ key, kind: "direct", updatedAt: 1 }], + }; + const navigation = sidebar.getSessionNavigationState(); + expect(navigation.selectedAgentId).toBe("main"); + expect(navigation.activeRowKey).toBe(key); + expect(navigation.visibleSessionRows.map((row) => row.key)).toEqual([key]); +}); diff --git a/ui/src/components/app-sidebar.people.test.ts b/ui/src/components/app-sidebar.people.test.ts new file mode 100644 index 000000000000..d014be19688b --- /dev/null +++ b/ui/src/components/app-sidebar.people.test.ts @@ -0,0 +1,11 @@ +/* @vitest-environment jsdom */ + +import "../test-helpers/app-sidebar-suite.ts"; +import "../test-helpers/app-sidebar-cases/agent-menu.ts"; +import "../test-helpers/app-sidebar-cases/roster-agent-first.ts"; +import "../test-helpers/app-sidebar-cases/roster.ts"; +import "../test-helpers/app-sidebar-cases/attention.ts"; +import "../test-helpers/app-sidebar-cases/attention-details.ts"; +import "../test-helpers/app-sidebar-cases/identity-menu.ts"; +import "../test-helpers/app-sidebar-cases/presence.ts"; +import "../test-helpers/app-sidebar-cases/presence-card.ts"; diff --git a/ui/src/components/app-sidebar.sessions.test.ts b/ui/src/components/app-sidebar.sessions.test.ts new file mode 100644 index 000000000000..459e338d788c --- /dev/null +++ b/ui/src/components/app-sidebar.sessions.test.ts @@ -0,0 +1,18 @@ +/* @vitest-environment jsdom */ + +import "../test-helpers/app-sidebar-suite.ts"; +import "../test-helpers/app-sidebar-cases/categorized-child-sessions.ts"; +import "../test-helpers/app-sidebar-cases/child-session-errors.ts"; +import "../test-helpers/app-sidebar-cases/child-sessions-cap.ts"; +import "../test-helpers/app-sidebar-cases/child-sessions.ts"; +import "../test-helpers/app-sidebar-cases/narration.ts"; +import "../test-helpers/app-sidebar-cases/outbox-badges.ts"; +import "../test-helpers/app-sidebar-cases/pull-request-state.ts"; +import "../test-helpers/app-sidebar-cases/session-indicators.ts"; +import "../test-helpers/app-sidebar-cases/session-delegated-activity.ts"; +import "../test-helpers/app-sidebar-cases/sessions.ts"; +import "../test-helpers/app-sidebar-cases/session-ownership.ts"; +import "../test-helpers/app-sidebar-cases/session-ownership-filtering.ts"; +import "../test-helpers/app-sidebar-cases/session-list-sections.ts"; +import "../test-helpers/app-sidebar-cases/sidebar-zone.ts"; +import "../test-helpers/app-sidebar-cases/plugin-session-list.ts"; diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts deleted file mode 100644 index 816ca73b3f05..000000000000 --- a/ui/src/components/app-sidebar.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* @vitest-environment jsdom */ - -import { expect, it } from "vitest"; -import { AppSidebarSessionNavigationElement } from "./app-sidebar-session-navigation.ts"; -import "../test-helpers/app-sidebar-suite.ts"; -import "../test-helpers/app-sidebar-cases/agent-menu.ts"; -import "../test-helpers/app-sidebar-cases/roster-agent-first.ts"; -import "../test-helpers/app-sidebar-cases/roster.ts"; -import "../test-helpers/app-sidebar-cases/attention.ts"; -import "../test-helpers/app-sidebar-cases/attention-details.ts"; -import "../test-helpers/app-sidebar-cases/basics.ts"; -import "../test-helpers/app-sidebar-cases/footer-status.ts"; -import "../test-helpers/app-sidebar-cases/catalog-compat.ts"; -import "../test-helpers/app-sidebar-cases/catalog-live-events.ts"; -import "../test-helpers/app-sidebar-cases/catalog-project-activity.ts"; -import "../test-helpers/app-sidebar-cases/catalog-row-lifecycle.ts"; -import "../test-helpers/app-sidebar-cases/catalog-live.ts"; -import "../test-helpers/app-sidebar-cases/catalog-pagination-visibility.ts"; -import "../test-helpers/app-sidebar-cases/catalog-reconnect.ts"; -import "../test-helpers/app-sidebar-cases/catalog-live-errors.ts"; -import "../test-helpers/app-sidebar-cases/catalog-live-state.ts"; -import "../test-helpers/app-sidebar-cases/catalog-ownership.ts"; -import "../test-helpers/app-sidebar-cases/catalog-terminal-owner.ts"; -import "../test-helpers/app-sidebar-cases/catalog-pages.ts"; -import "../test-helpers/app-sidebar-cases/categorized-child-sessions.ts"; -import "../test-helpers/app-sidebar-cases/child-session-errors.ts"; -import "../test-helpers/app-sidebar-cases/child-sessions-cap.ts"; -import "../test-helpers/app-sidebar-cases/child-sessions.ts"; -import "../test-helpers/app-sidebar-cases/group-mutations.ts"; -import "../test-helpers/app-sidebar-cases/identity-menu.ts"; -import "../test-helpers/app-sidebar-cases/interactions.ts"; -import "../test-helpers/app-sidebar-cases/new-group-dialog.ts"; -import "../test-helpers/app-sidebar-cases/narration.ts"; -import "../test-helpers/app-sidebar-cases/outbox-badges.ts"; -import "../test-helpers/app-sidebar-cases/pull-request-state.ts"; -import "../test-helpers/app-sidebar-cases/presence.ts"; -import "../test-helpers/app-sidebar-cases/presence-card.ts"; -import "../test-helpers/app-sidebar-cases/section-reordering.ts"; -import "../test-helpers/app-sidebar-cases/session-delete-access.ts"; -import "../test-helpers/app-sidebar-cases/session-indicators.ts"; -import "../test-helpers/app-sidebar-cases/session-delegated-activity.ts"; -import "../test-helpers/app-sidebar-cases/session-mutations.ts"; -import "../test-helpers/app-sidebar-cases/sidebar-scroll.ts"; -import "../test-helpers/app-sidebar-cases/sessions.ts"; -import "../test-helpers/app-sidebar-cases/session-ownership.ts"; -import "../test-helpers/app-sidebar-cases/session-ownership-filtering.ts"; -import "../test-helpers/app-sidebar-cases/session-list-sections.ts"; -import "../test-helpers/app-sidebar-cases/sidebar-zone.ts"; -import "../test-helpers/app-sidebar-cases/transient-menus.ts"; -import "../test-helpers/app-sidebar-cases/plugin-session-list.ts"; - -it.each([0, 1])("resolves %i sidebar rows before agent selection is available", (count) => { - const sidebar = document.createElement("openclaw-app-sidebar"); - if (!(sidebar instanceof AppSidebarSessionNavigationElement)) { - throw new Error("expected the registered sidebar"); - } - const key = "agent:main:main"; - sidebar.sessionKey = key; - sidebar.sessionData.sessionsAgentId = "main"; - sidebar.sessionData.sessionsResult = { - ts: 1, - path: "", - count, - defaults: { modelProvider: null, model: null, contextTokens: null }, - sessions: count === 0 ? [] : [{ key, kind: "direct", updatedAt: 1 }], - }; - const navigation = sidebar.getSessionNavigationState(); - expect(navigation.selectedAgentId).toBe("main"); - expect(navigation.activeRowKey).toBe(key); - expect(navigation.visibleSessionRows.map((row) => row.key)).toEqual([key]); -}); diff --git a/ui/src/lib/hover-marquee.test.ts b/ui/src/lib/hover-marquee.test.ts index 1fbad3834f49..59957d55d6fd 100644 --- a/ui/src/lib/hover-marquee.test.ts +++ b/ui/src/lib/hover-marquee.test.ts @@ -121,6 +121,70 @@ describe("hover marquee measurement budget", () => { true, ); expect(operations.lastIndexOf("read")).toBeLessThan(operations.indexOf("write")); + + Object.defineProperty(labels[0]!, "clientWidth", { configurable: true, value: 0 }); + operations.length = 0; + clock.resize(labels[0]!); + clock.resize(labels[1]!); + clock.flush(); + expect(labels[0]!.classList.contains("hover-marquee--overflowing")).toBe(false); + expect(labels[1]!.classList.contains("hover-marquee--overflowing")).toBe(true); + expect(operations).toEqual(["read", "write", "write"]); + }); + + it("avoids style resolution at zero width and resumes through the existing resize observer", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const clock = measurementClock(); + const { labels, container } = denseTitles(1); + const label = labels[0]!; + const host = container.querySelector("a")!; + let width = 0; + Object.defineProperty(label, "clientWidth", { configurable: true, get: () => width }); + const style = vi.spyOn(globalThis, "getComputedStyle"); + const expectNoLabelStyleRead = () => + expect(style.mock.calls.filter(([element]) => element === label)).toEqual([]); + const expectResting = () => { + expect(label.classList.contains("hover-marquee--overflowing")).toBe(false); + expect(label.classList.contains("hover-marquee--scrolling")).toBe(false); + expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe(""); + expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe(""); + expect(vi.getTimerCount()).toBe(0); + }; + host.tabIndex = 0; + host.focus(); + // Reused jsdom windows retain mouse modality from earlier files. + host.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(document.activeElement).toBe(host); + expect(host.matches(":focus-visible")).toBe(true); + // Focusing collapses jsdom's selection and queues a separate selectionchange event. + vi.advanceTimersByTime(0); + clock.flush(); + expectResting(); + expectNoLabelStyleRead(); + + for (const revealBeforeHiding of [false, true]) { + width = 100; + clock.resize(label); + clock.flush(); + expect(label.classList.contains("hover-marquee--overflowing")).toBe(true); + expect(label.style.getPropertyValue("--hover-marquee-shift")).not.toBe(""); + expect(label.style.getPropertyValue("--hover-marquee-duration")).not.toBe(""); + expect(vi.getTimerCount()).toBe(1); + if (revealBeforeHiding) { + vi.advanceTimersByTime(500); + clock.flush(); + expect(label.classList.contains("hover-marquee--scrolling")).toBe(true); + } + style.mockClear(); + width = 0; + clock.resize(label); + clock.flush(); + expectResting(); + vi.advanceTimersByTime(500); + clock.flush(); + expectResting(); + expectNoLabelStyleRead(); + } }); it("skips unchanged titles but refreshes content, class, direction, and viewport changes", async () => { diff --git a/ui/src/lib/hover-marquee.ts b/ui/src/lib/hover-marquee.ts index 6f729d821218..b30cf837c575 100644 --- a/ui/src/lib/hover-marquee.ts +++ b/ui/src/lib/hover-marquee.ts @@ -150,29 +150,28 @@ class HoverMarqueeDirective extends AsyncDirective { this.visibilityObserver = undefined; this.visible = true; } + const width = label.clientWidth; + if (width <= 0) { + return () => this.clearOverflow(label); + } const text = this.text!; const style = getComputedStyle(label); const padding = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight); - const overflow = - style.whiteSpace === "nowrap" && label.clientWidth > 0 - ? text.scrollWidth + padding - label.clientWidth - : 0; + const overflow = style.whiteSpace === "nowrap" ? text.scrollWidth + padding - width : 0; const clipped = overflow > (this.options.loop ? 0 : 1); + if (!clipped) { + return () => this.clearOverflow(label); + } const active = this.host.matches(":hover, :focus-visible") || Boolean(this.host.querySelector(":focus-visible")) || // Touch opens the existing identity menu; its trigger keeps revealing // the name while focus moves into the portaled menu. (this.options.loop && this.host.getAttribute("aria-expanded") === "true"); - if (!clipped || !active || !this.visible || this.motion?.matches) { + if (!active || !this.visible || this.motion?.matches) { return () => { label.classList.toggle("hover-marquee--overflowing", clipped); this.stop(); - if (!clipped) { - label.style.removeProperty("--hover-marquee-shift"); - label.style.removeProperty("--hover-marquee-duration"); - this.shift = 0; - } }; } const fade = Number.parseFloat(style.getPropertyValue("--hover-marquee-fade-width")); @@ -209,6 +208,14 @@ class HoverMarqueeDirective extends AsyncDirective { }; }; + private clearOverflow(label: HTMLElement) { + label.classList.toggle("hover-marquee--overflowing", false); + this.stop(); + label.style.removeProperty("--hover-marquee-shift"); + label.style.removeProperty("--hover-marquee-duration"); + this.shift = 0; + } + private stop() { window.clearTimeout(this.timer); this.timer = undefined; diff --git a/ui/src/test-helpers/app-sidebar-cases/footer-status.ts b/ui/src/test-helpers/app-sidebar-cases/footer-status.ts index 3c5a98b0ba72..cfad369a6283 100644 --- a/ui/src/test-helpers/app-sidebar-cases/footer-status.ts +++ b/ui/src/test-helpers/app-sidebar-cases/footer-status.ts @@ -45,15 +45,17 @@ function setNativeGatewayTestState(snapshot: SidebarNativeGatewayTestSnapshot): nativeWindow["__OPENCLAW_NATIVE_GATEWAYS__"] = snapshot; } -afterEach(() => { - const nativeWindow = window as SidebarNativeGatewayTestWindow; - Reflect.deleteProperty(nativeWindow, "__OPENCLAW_NATIVE_WEB_CHROME__"); - Reflect.deleteProperty(nativeWindow, "__OPENCLAW_NATIVE_GATEWAYS__"); - Object.assign(CONTROL_UI_BUILD_INFO as MutableControlUiBuildInfo, ORIGINAL_CONTROL_UI_BUILD_INFO); - vi.useRealTimers(); -}); - describe("AppSidebar gateway footer subtitle", () => { + afterEach(() => { + const nativeWindow = window as SidebarNativeGatewayTestWindow; + Reflect.deleteProperty(nativeWindow, "__OPENCLAW_NATIVE_WEB_CHROME__"); + Reflect.deleteProperty(nativeWindow, "__OPENCLAW_NATIVE_GATEWAYS__"); + Object.assign( + CONTROL_UI_BUILD_INFO as MutableControlUiBuildInfo, + ORIGINAL_CONTROL_UI_BUILD_INFO, + ); + }); + const twoGateways = { gateways: [ { id: "local", name: "Local Gateway", isPrimary: true, health: "ok" }, diff --git a/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts b/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts index 827f42103b8b..942e58850877 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-indicators.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT } from "../../../../src/gateway/control-ui-contract.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ApplicationGatewaySnapshot } from "../../app/context.ts"; @@ -13,11 +13,6 @@ function expectEmptyLead(row: Element | null) { expect(lead?.childElementCount).toBe(0); } -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - describe("AppSidebar session indicators", () => { it("removes a session stripe when a changed event clears its color", async () => { const key = "agent:main:color"; diff --git a/ui/src/test-helpers/app-sidebar-setup.ts b/ui/src/test-helpers/app-sidebar-setup.ts index 2ff85400851b..1ee854c53569 100644 --- a/ui/src/test-helpers/app-sidebar-setup.ts +++ b/ui/src/test-helpers/app-sidebar-setup.ts @@ -58,6 +58,8 @@ export function setupSidebarTest() { }); afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); vi.useRealTimers(); await vi.dynamicImportSettled(); // Removing a prompt's DOM does not settle its promise or release its reentrancy guard. diff --git a/ui/src/test-helpers/bun-css-tokenizer.setup.ts b/ui/src/test-helpers/bun-css-tokenizer.setup.ts new file mode 100644 index 000000000000..cd430b6ab454 --- /dev/null +++ b/ui/src/test-helpers/bun-css-tokenizer.setup.ts @@ -0,0 +1,29 @@ +import { realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +type OptimizerTarget = (...args: never[]) => unknown; + +if (process.versions.bun) { + const require = createRequire(import.meta.url); + // Bun is optional; describe only the native optimizer API used by this setup. + const { noInline } = require("bun:jsc") as { noInline: (target: OptimizerTarget) => void }; + const cssValuesOwner = path.join( + path.dirname(require.resolve("jsdom/package.json")), + "lib/jsdom/living/css/helpers/css-values.js", + ); + const requireFromCssValues = createRequire(cssValuesOwner); + const colorEntry = realpathSync(requireFromCssValues.resolve("@asamuzakjp/css-color")); + const requireFromColor = createRequire(colorEntry); + const tokenizerEntry = realpathSync(requireFromColor.resolve("@csstools/css-tokenizer")); + // Load jsdom's native ESM instance; a Vite import can bind another tokenizer. + const { tokenizer } = requireFromColor(tokenizerEntry) as { + tokenizer: (input: { css: string }) => { + endOfFile: OptimizerTarget; + }; + }; + const instance = tokenizer({ css: "" }); + // This predicate shares its native executable with later instances. Keep only + // the EOF check uninlined to avoid the pinned fork's ordered CSS loop. + noInline(instance.endOfFile); +} diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 59756860a0b8..1ea2dea99bb9 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -12,7 +12,10 @@ import { matchesVitestGlob, relativizeScopedPatterns, } from "../test/vitest/vitest.pattern-file.ts"; -import { loadVitestPerformanceConfig } from "../test/vitest/vitest.performance-config.ts"; +import { + createVitestProjectCachePlugin, + loadVitestPerformanceConfig, +} from "../test/vitest/vitest.performance-config.ts"; import { createRedactingReporterPlugin } from "../test/vitest/vitest.reporters.ts"; import { jsdomOptimizedDeps, @@ -24,6 +27,7 @@ import { uiNodeDrivenBrowserTestFiles, uiTimingTestFiles, } from "../test/vitest/vitest.ui-paths.mjs"; +import { UiRuntimePartitionSequencer } from "../test/vitest/vitest.ui-runtime-sequencer.ts"; import { controlUiLocaleModulesPlugin } from "./config/control-ui-locales.ts"; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -133,6 +137,10 @@ const sharedUiTestConfig = { testTimeout: 60_000, hookTimeout: 60_000, } as const; +const nodeSetupFiles = [ + "./src/test-helpers/bun-css-tokenizer.setup.ts", + "./src/test-helpers/lit-warnings.setup.ts", +]; const nodeDrivenBrowserLayoutTests = relativizeScopedPatterns(uiNodeDrivenBrowserTestFiles, "ui"); const timingTests = relativizeScopedPatterns(uiTimingTestFiles, "ui"); const mockRegistryUnitTests = uiIsolatedTestFiles.map((testFile) => testFile.slice("ui/".length)); @@ -172,7 +180,11 @@ const chromiumLaunchOptions = resolveChromiumLaunchOptions(); export function createUiBrowserVitestConfig(env = process.env): ViteUserConfig { return defineProject({ root: here, - plugins: [controlUiLocaleModulesPlugin(), createRedactingReporterPlugin()], + plugins: [ + controlUiLocaleModulesPlugin(), + createVitestProjectCachePlugin(), + createRedactingReporterPlugin(), + ], optimizeDeps: { include: [ // These controls share wa-popup's eager registration. Optimize them together @@ -250,19 +262,22 @@ export function createUiBrowserVitestConfig(env = process.env): ViteUserConfig { export default defineConfig({ root: here, - plugins: [createRedactingReporterPlugin()], + plugins: [createVitestProjectCachePlugin(), createRedactingReporterPlugin()], resolve: { alias: workspaceSourceAliases, }, test: { ...sharedUiTestConfig, + ...(process.env.OPENCLAW_VITEST_POST_SHARD_INCLUDE_FILE + ? { sequence: { sequencer: UiRuntimePartitionSequencer } } + : {}), maxWorkers: sharedVitestConfig.test.maxWorkers, reporters: sharedVitestConfig.test.reporters, // These projects already own their complete plugins, aliases, and test config. projects: [ { extends: false, - plugins: [controlUiLocaleModulesPlugin()], + plugins: [controlUiLocaleModulesPlugin(), createVitestProjectCachePlugin()], resolve: { alias: workspaceSourceAliases, }, @@ -287,12 +302,12 @@ export default defineConfig({ ...mockRegistryUnitTests, ], environment: "jsdom", - setupFiles: ["./src/test-helpers/lit-warnings.setup.ts"], + setupFiles: nodeSetupFiles, }, }, { extends: false, - plugins: [controlUiLocaleModulesPlugin()], + plugins: [controlUiLocaleModulesPlugin(), createVitestProjectCachePlugin()], resolve: { alias: workspaceSourceAliases, }, @@ -305,12 +320,12 @@ export default defineConfig({ name: "unit-mock-registry", include: includeUiTests([...mockRegistryUnitTests]), environment: "jsdom", - setupFiles: ["./src/test-helpers/lit-warnings.setup.ts"], + setupFiles: nodeSetupFiles, }, }, { extends: false, - plugins: [controlUiLocaleModulesPlugin()], + plugins: [controlUiLocaleModulesPlugin(), createVitestProjectCachePlugin()], resolve: { alias: workspaceSourceAliases, }, @@ -328,13 +343,13 @@ export default defineConfig({ ...nodeDrivenBrowserLayoutTests, ]), environment: "jsdom", - setupFiles: ["./src/test-helpers/lit-warnings.setup.ts"], + setupFiles: nodeSetupFiles, }, }, { ...createUiBrowserVitestConfig(), extends: false }, { extends: false, - plugins: [controlUiLocaleModulesPlugin()], + plugins: [controlUiLocaleModulesPlugin(), createVitestProjectCachePlugin()], resolve: { alias: workspaceSourceAliases }, test: { ...sharedUiTestConfig, @@ -345,7 +360,7 @@ export default defineConfig({ sequence: { groupOrder: 1 }, include: includeUiTests(timingTests), environment: "jsdom", - setupFiles: ["./src/test-helpers/lit-warnings.setup.ts"], + setupFiles: nodeSetupFiles, }, }, ],