diff --git a/.github/CI.md b/.github/CI.md index 61fffc8d844..f5d005489f6 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -4,6 +4,10 @@ The `changes` job runs Turborepo 2.9.14's `affectedTasks` query before installin A PR compares its merge base with the checked-out merge commit. Main/staging pushes execute all checks; the parent `ci` workflow skips checks on `dev` pushes, while PRs targeting `dev` still run checks. A manual `checks` run executes all checks unless supplied a full base commit SHA to reproduce a scoped plan. Missing history, an empty diff, unknown files outside the workspace graph, root script/CI changes, or a planner error run every check. Missing selection outputs fail the aggregate gate. The gate also rejects an omitted dependency, any failed/cancelled job, and any skip that its explicit selection flag did not authorize. +Dedicated docs verification uses the same planner, comparing pushes with their previous commit and PRs with their merge base. Site acceptance follows `docs#build` and its transitive package dependencies. The app's docs-consumer task depends on server runtime and that docs build: backend-only changes run the platform consumer against the docs app, while site changes also run corpus and browser checks. Independent SDK and unit-only edits skip those production jobs. The required `docs HTTP` gate verifies explicit site/platform selections before accepting any skip. + +Docs production builds run once and publish an artifact to isolated HTTP, corpus, Chromium, and WebKit jobs. Source checks keep their own checkout because generator verification mutates inputs. Content caches use separate lint/source/build namespaces; generation validates every restore, repairs missing declarations, and removes obsolete modules. The Next build cache keeps its supported incremental behavior. Content and Next caches have size limits, and Next cache files expire after seven days; successful content eviction happens after all consumers finish. + | Change | Expensive checks selected | | --- | --- | | Independent SDK | That workspace's tests and type-check; no app build, services, or browsers | @@ -28,11 +32,17 @@ Database integration uses 4-vCPU Blacksmith runners with sticky dependency disks GitHub runners install dependencies directly rather than transferring Bun and `node_modules` archives. In a same-source paired comparison, those restores took 26.2 seconds before an 82-millisecond install, while a clean install took 12.42 seconds. This is one paired observation; retain the policy only while repeated CI timings support it. Blacksmith keeps its sticky dependency disks. GitHub Turbo output caches remain enabled for jobs that request them, with a commit-specific key and a task/event/fork-scoped restore prefix, so a rerun can reuse an exact entry without creating another archive. Push jobs never restore pull-request entries. +Closing a same-repository pull request deletes only its GitHub merge-ref caches after verifying that it remains closed or merged. The regular `pull_request` workflow skips forks and Dependabot because their tokens cannot delete caches; those entries retain GitHub's normal cache eviction. Cleanup checks out no code and never selects a branch-wide or default-branch cache. Blacksmith sticky disks retain their separate lifecycle. + Memory-heavy builds and browser jobs retain appropriately sized Blacksmith runners. Electron uses 4-vCPU Blacksmith: both shards passed within 14 seconds of the 8-vCPU control; the free GitHub rest shard was slower and its lease run stalled. Every HTTP and browser group consumes the same production build in an isolated job/database. Electron also uses that build and skips its development-route warm-up. Local HTTP and Electron development runs retain their dev-server behavior. Build inputs exclude test-only files so a harness edit can reuse an unchanged production build. -Desktop inbox protocol, assistant actions UI, and organization search UI run as three parallel HTTP jobs with isolated databases, apps, and reports. They retain one acceptance task boundary, so changing any of their harnesses selects all three jobs. Desktop inbox needs no browser. The UI jobs and browser extension install Chromium while their app starts, waiting for both before executing assertions; browser-install failures still fail the job and preserve the install log. No acceptance assertions are removed by the split. +Desktop inbox protocol, assistant actions UI, and organization search UI run as three parallel HTTP jobs with isolated databases, apps, and reports. Each harness owns its acceptance task, so an edit selects its own job. The inbox protocol task depends on server and realtime code; the browser tasks also depend on workspace UI. Desktop inbox needs no browser. The UI jobs and browser extension install Chromium while their app starts, waiting for both before executing assertions; browser-install failures still fail the job and preserve the install log. No acceptance assertions are removed by the split. + +Live Electron acceptance tracks its live-app suite, fixtures, and Playwright configuration separately from shell-only smoke tests. Changing a shell-only smoke case runs the dedicated desktop workflow without starting the app's production build and live lease checks. + +Desktop smoke weights also stay outside the desktop runtime task. Editing weights reruns desktop partition coverage without selecting live-app acceptance. Browser extension acceptance runs native interaction and access-revalidation groups in parallel jobs with isolated databases, apps, browser profiles, and reports. Both retain the same task boundary, so an affected extension harness selects both groups. Native streaming and first-open capture checks retain real timers. Access checks install Playwright's clock before the app mounts: advancing production polling timers preserves real HTTP and database authorization checks without waiting through each polling interval. Capture timestamps originate in Chromium's isolated world, so the harness restores wall time after each advance. The local `browser-extension` group still runs the complete suite; live-provider runs require this complete mode and retain native timing throughout. @@ -53,9 +63,11 @@ The long-import and lease-expiration checks remain: they prove distinct survival Local planner validation is `bun run test:scripts scripts/ci-changes.test.ts scripts/ci-gate.test.ts`. It executes real Turbo queries in small Git fixtures; no application build or database is required. Full builds, services, browser suites, and Electron checks run on hosted CI. -The separate `desktop-e2e` workflow retains its desktop-specific path filters. Its macOS shell smoke suite runs whole files across three isolated runners, with one worker per runner. `scripts/desktop-e2e.ts` asks Playwright to discover the complete suite, then assigns longer files first to the currently lightest shard using `apps/desktop/e2e-durations.json`. Exact escaped file filters preserve serial fixtures. Unmeasured files receive the median measured weight, deleted files cannot select tests, and invalid or empty selections fail. Normal local runs, packaging smoke and the weekly Electron canary keep their existing commands. +The separate `desktop-e2e` workflow retains its desktop-specific path filters. Its macOS shell smoke suite runs whole files across four isolated runners, with one worker per runner. The matrix owns the partition count; job names and runner arguments use `strategy.job-total`. `scripts/desktop-e2e.ts` asks Playwright to discover the complete suite, then assigns longer files first to the currently lightest shard using `apps/desktop/e2e-durations.json`. Exact escaped file filters preserve serial fixtures. Unmeasured files receive the median measured weight, deleted files cannot select tests, and invalid or empty selections fail. Normal local runs, packaging smoke and the weekly Electron canary keep their existing commands. + +The current weights are median test durations in seconds from runs [41496623284](https://github.com/simstudioai/sim/actions/runs/41496623284), [41497346251](https://github.com/simstudioai/sim/actions/runs/41497346251), and [41502340540](https://github.com/simstudioai/sim/actions/runs/41502340540). Refresh them from complete `desktop-smoke-reports-*` artifacts when the workload changes: sum `tests[].results[].duration` by `spec.file` in each run, take the median across runs, and divide milliseconds by 1,000. The weights steer scheduling only; Playwright discovery owns coverage. Before adopting new weights, compare repeated paired runs and the complete case/status multiset, including skipped cases and retries. -The initial weights are median test durations in seconds from runs [39734295953](https://github.com/simstudioai/sim/actions/runs/39734295953), [39833640524](https://github.com/simstudioai/sim/actions/runs/39833640524), and [39836596108](https://github.com/simstudioai/sim/actions/runs/39836596108). Refresh them from complete `desktop-smoke-reports-*` artifacts when the workload changes: sum `tests[].results[].duration` by `spec.file` in each run, take the median across runs, and divide milliseconds by 1,000. The weights steer scheduling only; Playwright discovery owns coverage. Before adopting new weights, compare repeated paired runs and the complete case/status multiset, including skipped cases and retries. +Desktop native outputs are keyed by source, build recipes, compiler, SDK, OS, architecture, and Node header bytes. Every build still validates restored checksums, executable modes, universal architectures, and the helper signature; invalid or incomplete outputs rebuild. Native-input pushes to main/staging seed trusted Node 22 and 24 caches without repeating the PR smoke suite. PR cache entries never populate trusted branch caches. Each shard uploads its coverage/timing report, the complete discovery report, and its deterministic shard plan. `bun run test:scripts scripts/duration-shards.test.ts scripts/desktop-e2e.test.ts` checks partition coverage, unknown files, deterministic ordering, and balancing. The CLI regression executes tiny browser-free Playwright fixtures to prove that imported test factories and paths containing regex characters are selected correctly; it launches no Electron or browser. diff --git a/.github/actions/desktop-native-cache/action.yml b/.github/actions/desktop-native-cache/action.yml new file mode 100644 index 00000000000..3ad90cffda2 --- /dev/null +++ b/.github/actions/desktop-native-cache/action.yml @@ -0,0 +1,16 @@ +name: Cache desktop native outputs +description: Reuse validated native outputs only for matching source and toolchain inputs +runs: + using: composite + steps: + - name: Identify native build inputs + id: inputs + shell: bash + working-directory: apps/desktop + run: echo "key=$(bun run scripts/native-build-cache.ts)" >> "$GITHUB_OUTPUT" + - name: Restore native compilation outputs + continue-on-error: true + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5 + with: + path: apps/desktop/dist/native + key: desktop-native-v1-${{ runner.os }}-${{ steps.inputs.outputs.key }} diff --git a/.github/actions/docs-content-cache/action.yml b/.github/actions/docs-content-cache/action.yml new file mode 100644 index 00000000000..3157f30f14d --- /dev/null +++ b/.github/actions/docs-content-cache/action.yml @@ -0,0 +1,68 @@ +name: Docs content cache +description: Reuse validated incremental documentation compilation without sharing concurrent writer snapshots. +inputs: + provider: + description: The CI_PROVIDER repo variable. + required: false + default: '' + namespace: + description: A distinct name for each concurrently writing job. + required: true + generate: + description: Generate and validate content after restoring it. Set false only when the following build already runs content:generate. + required: false + default: 'true' + next-cache: + description: Also restore the incremental Next build cache. + required: false + default: 'false' +runs: + using: composite + steps: + - name: Identify the compiler runtime + id: runtime + shell: bash + env: + CACHE_NAMESPACE: ${{ inputs.namespace }} + GENERATE: ${{ inputs.generate }} + NEXT_CACHE: ${{ inputs.next-cache }} + run: | + [[ "$CACHE_NAMESPACE" =~ ^[a-z0-9][a-z0-9-]*$ ]] + [[ "$GENERATE" == true || "$GENERATE" == false ]] + [[ "$NEXT_CACHE" == true || "$NEXT_CACHE" == false ]] + toolchain=$(node --version) + toolchain="$toolchain-$(bun --version)" + echo "toolchain=$toolchain" >> "$GITHUB_OUTPUT" + compiler=$(git ls-files -s -- bun.lock package.json patches apps/docs/package.json apps/docs/scripts/compile-content.ts apps/docs/lib apps/docs/components/workflow-preview packages | sha256sum | awk '{ print $1 }') + echo "compiler=$compiler" >> "$GITHUB_OUTPUT" + - name: Restore incremental content + continue-on-error: true + uses: ./.github/actions/cache + with: + provider: ${{ inputs.provider }} + key: ${{ github.repository }}-docs-content-v1-${{ inputs.namespace }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.toolchain }}-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && format('-fork-{0}', github.event.pull_request.number) || '' }}-${{ steps.runtime.outputs.compiler }} + path: ./apps/docs/.content + - name: Bound restored content + shell: bash + run: bash .github/actions/docs-content-cache/prune.sh content + - name: Restore incremental Next build + if: inputs.next-cache == 'true' + continue-on-error: true + uses: ./.github/actions/cache + with: + provider: ${{ inputs.provider }} + key: ${{ github.repository }}-docs-next-v1-${{ inputs.namespace }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.runtime.outputs.toolchain }}-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && format('-fork-{0}', github.event.pull_request.number) || '' }}-${{ hashFiles('bun.lock') }} + path: ./apps/docs/.next/cache + - name: Bound the incremental build cache + if: inputs.next-cache == 'true' + shell: bash + run: bash .github/actions/docs-content-cache/prune.sh next + - name: Generate and validate documentation modules + id: generate + if: inputs.generate == 'true' + shell: bash + run: bun run --cwd apps/docs content:generate --prune + - name: Bound content after an unsuccessful generation + if: always() && inputs.generate == 'true' && steps.generate.outcome != 'success' + shell: bash + run: bash .github/actions/docs-content-cache/prune.sh content diff --git a/.github/actions/docs-content-cache/prune.sh b/.github/actions/docs-content-cache/prune.sh new file mode 100644 index 00000000000..3e4e55c10cd --- /dev/null +++ b/.github/actions/docs-content-cache/prune.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +case ${1:-} in + content) + cache=apps/docs/.content + maximum_kilobytes=524288 + ;; + next) + cache=apps/docs/.next/cache + maximum_kilobytes=2097152 + ;; + *) echo 'Usage: prune.sh ' >&2; exit 2 ;; +esac +if [ ! -d "$cache" ]; then exit 0; fi +if [ "$1" = next ]; then find "$cache" -type f -mtime +7 -delete; fi +kilobytes=$(du -sk "$cache" | awk '{ print $1 }') +if [ "$kilobytes" -gt "$maximum_kilobytes" ]; then + find "$cache" -mindepth 1 -delete +fi diff --git a/.github/ci-suites.json b/.github/ci-suites.json index df2dc16de3a..f695a93a56a 100644 --- a/.github/ci-suites.json +++ b/.github/ci-suites.json @@ -18,11 +18,11 @@ }, { "group": "assistant-actions", - "task": "ci:e2e-desktop-inbox" + "task": "ci:e2e-assistant-actions" }, { "group": "organization-search", - "task": "ci:e2e-desktop-inbox" + "task": "ci:e2e-organization-search" }, { "group": "browser-extension-native", diff --git a/.github/scripts/ci-plan.gql b/.github/scripts/ci-plan.gql index b2a0e6c184f..78dcbf3eaab 100644 --- a/.github/scripts/ci-plan.gql +++ b/.github/scripts/ci-plan.gql @@ -3,6 +3,8 @@ query($base: String!, $head: String!) { affectedTasks(base: $base, head: $head, tasks: [ "test", "build", "type-check", "ci:integration", "ci:desktop-live", "ci:e2e-scim", "ci:e2e-cli", "ci:e2e-stop-after", "ci:e2e-desktop-inbox", + "ci:e2e-assistant-actions", "ci:e2e-organization-search", + "ci:e2e-docs-platform", "ci:e2e-browser-extension", "ci:e2e-project-files", "ci:e2e-mobile", "ci:e2e-seo" ]) { items { diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml new file mode 100644 index 00000000000..c85f428dfdf --- /dev/null +++ b/.github/workflows/cache-cleanup.yml @@ -0,0 +1,38 @@ +name: cache cleanup + +on: + pull_request: + types: [closed] + +permissions: + contents: read + +concurrency: + group: cache-cleanup-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + cleanup: + if: >- + github.event.pull_request.head.repo.full_name == github.repository + && github.event.pull_request.user.login != 'dependabot[bot]' + && github.actor != 'dependabot[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: write + pull-requests: read + steps: + - name: Remove caches scoped to the closed pull request + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + state=$(gh pr view "$PR_NUMBER" --json state --jq .state) + case "$state" in + OPEN) exit 0 ;; + CLOSED|MERGED) ;; + *) echo "::error::Unexpected pull request state: $state"; exit 1 ;; + esac + gh cache delete --all --ref "refs/pull/${PR_NUMBER}/merge" --succeed-on-no-caches diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 53c50e749d8..ad8bb32f0f7 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -685,7 +685,10 @@ jobs: "$RUNNER_TEMP/actionlint" -color -shellcheck= -pyflakes= - name: Generate documentation modules for import audits - run: bun run --cwd apps/docs content:generate + uses: ./.github/actions/docs-content-cache + with: + provider: ${{ vars.CI_PROVIDER }} + namespace: lint # Every zero-argument `check:*` script, run concurrently. The list is derived in # scripts/run-audits.ts, which also writes the per-audit timing table to the job @@ -727,6 +730,10 @@ jobs: fi echo "✅ Schema and migrations are in sync" + - name: Bound documentation content cache + if: always() + run: bash .github/actions/docs-content-cache/prune.sh content + # Type errors in apps/sim's unit tests, ratcheted per file and TS code against a committed # baseline (scripts/check-test-types.ts). It type-checks the whole app with its ~3.4k test files: # lint's uncached apps/sim type-check alone takes ~35 s on this runner, so it gets its own job diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index b7d43ae85c1..bbf18bb6d15 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -1,6 +1,6 @@ name: desktop-e2e -# Smoke coverage of the real Electron shell on desktop changes. Three isolated runners +# Smoke coverage of the real Electron shell on desktop changes. Isolated runners # partition complete test files by measured duration; workers remain serial per runner. # The weekly # advisory canary against electron@latest so Chromium-cadence breakage surfaces @@ -11,6 +11,7 @@ on: paths: - '.github/workflows/desktop-e2e.yml' - '.github/workflows/desktop-release.yml' + - '.github/actions/desktop-native-cache/**' - 'apps/desktop/**' - 'apps/sim/app/_shell/desktop-update-*.tsx' - 'apps/sim/app/workspace/**/home/hooks/use-mothership-resize.ts' @@ -49,6 +50,17 @@ on: - 'package.json' - 'scripts/desktop-e2e.ts' - 'scripts/duration-shards.ts' + push: + branches: [main, staging] + paths: + - '.github/workflows/desktop-e2e.yml' + - '.github/actions/desktop-native-cache/**' + - 'apps/desktop/native/**' + - 'apps/desktop/scripts/build.ts' + - 'apps/desktop/scripts/build-native.ts' + - 'apps/desktop/scripts/native-build-cache.ts' + - 'apps/desktop/tsconfig.json' + - 'bun.lock' schedule: - cron: '23 9 * * 1' workflow_dispatch: @@ -61,15 +73,45 @@ concurrency: cancel-in-progress: true jobs: + native-cache: + name: native-cache (Node ${{ matrix.node }}) + if: github.event_name == 'push' + runs-on: macos-26 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node: ['22', '24.22.1'] + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.4 + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: ${{ matrix.node }} + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + - name: Restore native compilation + uses: ./.github/actions/desktop-native-cache + - name: Build desktop native outputs + working-directory: apps/desktop + run: bun run scripts/build-native.ts --computer-use + e2e: - name: e2e (${{ matrix.shard }}/3) - if: github.event_name != 'schedule' + name: e2e (${{ matrix.shard }}/${{ strategy.job-total }}) + if: github.event_name != 'schedule' && github.event_name != 'push' runs-on: macos-26 timeout-minutes: 30 strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4] steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -81,6 +123,11 @@ jobs: with: bun-version: 1.4.4 + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: 24.22.1 + # Electron, electron-builder and Playwright download their binaries on every cold install. All # three caches are keyed by version inside, so an older entry restored by prefix is harmless. - name: Cache Electron and Playwright downloads @@ -96,6 +143,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Restore native compilation + uses: ./.github/actions/desktop-native-cache + - name: Bundle main and preload working-directory: apps/desktop run: bun run build @@ -110,7 +160,7 @@ jobs: - name: Run Playwright _electron smoke suite working-directory: apps/desktop - run: bun run ../../scripts/desktop-e2e.ts ${{ matrix.shard }}/3 + run: bun run ../../scripts/desktop-e2e.ts ${{ matrix.shard }}/${{ strategy.job-total }} env: PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/desktop-smoke.json BACKGROUND_EXECUTOR_REPORT_PATH: test-results/background-executor-report.json @@ -154,6 +204,11 @@ jobs: with: bun-version: 1.4.4 + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: 24.22.1 + # Electron, electron-builder and Playwright download their binaries on every cold install. All # three caches are keyed by version inside, so an older entry restored by prefix is harmless. - name: Cache Electron and Playwright downloads @@ -175,6 +230,9 @@ jobs: working-directory: apps/desktop run: bun add --no-save -d --minimum-release-age=0 electron@latest + - name: Restore native compilation + uses: ./.github/actions/desktop-native-cache + - name: Bundle main and preload working-directory: apps/desktop run: bun run build @@ -205,7 +263,7 @@ jobs: package-smoke: name: package-smoke - if: github.event_name != 'schedule' + if: github.event_name != 'schedule' && github.event_name != 'push' runs-on: macos-26 timeout-minutes: 20 steps: @@ -239,6 +297,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Restore native compilation + uses: ./.github/actions/desktop-native-cache + - name: Bundle and package unsigned working-directory: apps/desktop env: diff --git a/.github/workflows/docs-negotiation.yml b/.github/workflows/docs-negotiation.yml index 64b55a001c7..14a52a52206 100644 --- a/.github/workflows/docs-negotiation.yml +++ b/.github/workflows/docs-negotiation.yml @@ -4,18 +4,40 @@ on: pull_request: paths: - 'apps/docs/**' + - 'apps/sim/**' + - 'packages/**' + - 'turbo.json' + - 'scripts/ci-plan.ts' + - '.github/scripts/ci-changes.sh' + - '.github/scripts/ci-plan.gql' + - '.github/ci-suites.json' - 'patches/next@*.patch' - 'package.json' - 'bun.lock' - '.github/workflows/docs-negotiation.yml' + - '.github/actions/docs-content-cache/**' + - '.github/actions/cache/**' + - '.github/actions/setup/**' + - '.github/scripts/stop-session.sh' push: branches: [staging] paths: - 'apps/docs/**' + - 'apps/sim/**' + - 'packages/**' + - 'turbo.json' + - 'scripts/ci-plan.ts' + - '.github/scripts/ci-changes.sh' + - '.github/scripts/ci-plan.gql' + - '.github/ci-suites.json' - 'patches/next@*.patch' - 'package.json' - 'bun.lock' - '.github/workflows/docs-negotiation.yml' + - '.github/actions/docs-content-cache/**' + - '.github/actions/cache/**' + - '.github/actions/setup/**' + - '.github/scripts/stop-session.sh' workflow_dispatch: inputs: benchmark: @@ -110,6 +132,7 @@ jobs: trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true' EXIT for attempt in $(seq 1 30); do curl -fsS "$DOCS_BENCHMARK_BASE_URL/introduction" -o /dev/null && break + if [ "$attempt" -eq 30 ]; then exit 1; fi sleep 1 done fi @@ -141,14 +164,154 @@ jobs: path: docs-preview-build.tar.gz retention-days: 3 - http: + changes: if: github.event_name != 'workflow_dispatch' || !inputs.benchmark - name: docs HTTP + name: docs changes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + docs: ${{ steps.classify.outputs.docs }} + docs_site: ${{ steps.classify.outputs.docs_site }} + docs_platform: ${{ steps.classify.outputs.docs_platform }} + docs_matrix: ${{ steps.classify.outputs.docs_matrix }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + fetch-depth: 0 + filter: blob:none + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.4 + - name: Install only the pinned task planner + continue-on-error: true + run: bun install --global "turbo@$(jq -r .devDependencies.turbo package.json)" + - name: Attribute documentation changes + id: classify + env: + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + CI_PLAN_PATH: ${{ runner.temp }}/docs-ci-plan.json + run: bash .github/scripts/ci-changes.sh "$BASE" >> "$GITHUB_OUTPUT" + - name: Upload documentation attribution + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docs-ci-plan + path: ${{ runner.temp }}/docs-ci-plan.json + retention-days: 7 + + build: + needs: changes + if: needs.changes.outputs.docs == 'true' + name: docs build + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'linux-x64-8-core' }} + timeout-minutes: 45 + env: + DATABASE_URL: postgresql://docs_test:docs_test@127.0.0.1:1/docs_test + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + fetch-depth: 0 + filter: blob:none + - name: Setup workspace + uses: ./.github/actions/setup + with: + provider: ${{ vars.CI_PROVIDER }} + - name: Restore incremental documentation build inputs + uses: ./.github/actions/docs-content-cache + with: + provider: ${{ vars.CI_PROVIDER }} + namespace: docs-build + generate: 'false' + next-cache: 'true' + - name: Build production docs + run: bun run --cwd apps/docs build + - name: Package the production docs + run: tar -I 'zstd -T0 -3' -cf docs-build.tar.zst --exclude='.next/cache' -C apps/docs .next .content public/llms-full.txt + - name: Upload the production docs + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docs-build + path: docs-build.tar.zst + compression-level: 0 + retention-days: 1 + - name: Package production preview for visual inspection + if: inputs.preview_artifact + run: tar -czf docs-preview-build.tar.gz --exclude='.next/cache' -C apps/docs .next .content public/llms-full.txt + - name: Upload production preview + if: inputs.preview_artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docs-preview-build + path: docs-preview-build.tar.gz + retention-days: 3 + - name: Bound the incremental build cache + if: always() + run: | + bash .github/actions/docs-content-cache/prune.sh content + bash .github/actions/docs-content-cache/prune.sh next + + source: + needs: changes + if: needs.changes.outputs.docs_site == 'true' + name: docs source runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'linux-x64-8-core' }} timeout-minutes: 45 + env: + DATABASE_URL: postgresql://docs_test:docs_test@127.0.0.1:1/docs_test + DOCS_GENERATORS_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/generators/report.json + DOCS_COMPATIBILITY_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/compatibility.json + DOCS_HEADER_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/docs-headers.json + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + fetch-depth: 0 + filter: blob:none + - name: Setup workspace + uses: ./.github/actions/setup + with: + provider: ${{ vars.CI_PROVIDER }} + - name: Generate documentation modules + uses: ./.github/actions/docs-content-cache + with: + provider: ${{ vars.CI_PROVIDER }} + namespace: docs-source + - name: Verify generator idempotence and authored regions + id: generators + run: bun run --cwd apps/docs scripts/test-generators-e2e.ts + - name: Verify preserved routes, anchors and examples + if: ${{ !cancelled() && steps.generators.outcome != 'skipped' }} + run: bun run --cwd apps/docs content:verify-compatibility + - name: Verify native header edge cases + if: ${{ !cancelled() && steps.generators.outcome != 'skipped' }} + run: bun run --cwd apps/docs test:headers:e2e + - name: Bound the generated content cache + if: always() + run: bash .github/actions/docs-content-cache/prune.sh content + - name: Upload source regression reports + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: docs-source-reports + path: apps/docs/test-results/ + if-no-files-found: warn + retention-days: 14 + + acceptance: + if: needs.changes.outputs.docs == 'true' + needs: [changes, build] + name: docs acceptance (${{ matrix.group }}) + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'linux-x64-8-core' }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.changes.outputs.docs_matrix) }} services: search-postgres: - image: pgvector/pgvector:pg17 + image: ${{ (matrix.group == 'http' || matrix.group == 'platform') && 'pgvector/pgvector:pg17' || '' }} env: POSTGRES_USER: docs_test POSTGRES_PASSWORD: docs_test @@ -168,10 +331,8 @@ jobs: DOCS_SEARCH_E2E_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/search-postgres/report.json DOCS_INTERFACES_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/docs-interfaces.json DOCS_CORPUS_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/docs-corpus.json - DOCS_HEADER_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/docs-headers.json - DOCS_NAVIGATION_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/navigation/docs.json - DOCS_GENERATORS_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/generators/report.json - DOCS_COMPATIBILITY_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/compatibility.json + DOCS_NAVIGATION_BROWSER: ${{ matrix.group }} + DOCS_NAVIGATION_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/navigation-${{ matrix.group }}/docs.json DOCS_SAMPLES_REPORT_PATH: ${{ github.workspace }}/apps/docs/test-results/samples/report.json DOCS_SAMPLES_SYNTAX_LANGUAGES: javascript,python,bash,go,java,csharp DOCS_SAMPLES_HTTP_BASE_URL: http://127.0.0.1:3001 @@ -188,42 +349,37 @@ jobs: uses: ./.github/actions/setup with: provider: ${{ vars.CI_PROVIDER }} - - name: Build production docs - id: build - run: bun run --cwd apps/docs build - - name: Verify generator idempotence and authored regions - run: bun run --cwd apps/docs scripts/test-generators-e2e.ts - - name: Verify preserved routes, anchors and examples - if: always() && steps.build.outcome == 'success' - run: bun run --cwd apps/docs content:verify-compatibility - - name: Verify native header edge cases - if: always() && steps.build.outcome == 'success' - run: bun run --cwd apps/docs test:headers:e2e - - name: Verify isolated PostgreSQL search and index lifecycle - if: always() && steps.build.outcome == 'success' - run: bun run --cwd apps/docs test:search:postgres:e2e + - name: Download production docs + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: docs-build + - name: Extract production docs + run: tar -I zstd -xf docs-build.tar.zst -C apps/docs - name: Set up Java for copied request examples - if: always() && steps.build.outcome == 'success' + if: matrix.group == 'http' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: '17' - name: Set up .NET for copied request examples - if: always() && steps.build.outcome == 'success' + if: matrix.group == 'http' uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: dotnet-version: '8.0.x' - name: Set up Go for copied request examples - if: always() && steps.build.outcome == 'success' + if: matrix.group == 'http' uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: '1.25.x' cache: false - - name: Install browser for navigation checks - if: always() && steps.build.outcome == 'success' - run: bunx --no-install playwright install --with-deps chromium webkit - - name: Verify HTTP content negotiation - if: always() && steps.build.outcome == 'success' + - name: Install the selected navigation browser + if: matrix.group == 'chromium' || matrix.group == 'webkit' + env: + BROWSER: ${{ matrix.group }} + run: bunx --no-install playwright install --with-deps "$BROWSER" + - name: Verify production documentation + env: + DOCS_ACCEPTANCE_GROUP: ${{ matrix.group }} run: | mkdir -p apps/docs/test-results FEEDBACK_TLS_DIR=$(mktemp -d) @@ -232,34 +388,90 @@ jobs: openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1' -keyout "$DOCS_FEEDBACK_TEST_KEY" -out "$DOCS_FEEDBACK_TEST_CERT" 2>/dev/null export NODE_EXTRA_CA_CERTS="$DOCS_FEEDBACK_TEST_CERT" export DOCS_FEEDBACK_WEBHOOK_URL=https://127.0.0.1:3443/feedback - export DOCS_FEEDBACK_WEBHOOK_TOKEN=$(openssl rand -hex 32) - bun run --cwd apps/docs start --port 3001 >apps/docs/test-results/docs-server.log 2>&1 & + DOCS_FEEDBACK_WEBHOOK_TOKEN=$(openssl rand -hex 32) + export DOCS_FEEDBACK_WEBHOOK_TOKEN + APP_TAG="docs-$DOCS_ACCEPTANCE_GROUP-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$" + E2E_APP="$APP_TAG" setsid --wait bun run --cwd apps/docs start --port 3001 >apps/docs/test-results/docs-server.log 2>&1 & DOCS_SERVER_PID=$! - trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true; rm -rf "$FEEDBACK_TLS_DIR"' EXIT + finish() { + local status=$1 + trap - EXIT + if bash .github/scripts/stop-session.sh "$DOCS_SERVER_PID" "$APP_TAG"; then + wait "$DOCS_SERVER_PID" 2>/dev/null || true + else + status=1 + fi + rm -rf "$FEEDBACK_TLS_DIR" + exit "$status" + } + trap 'finish "$?"' EXIT + for attempt in $(seq 1 60); do + if ! kill -0 "$DOCS_SERVER_PID" 2>/dev/null; then + echo '::error::Documentation server exited before becoming ready.' + exit 1 + fi + if curl -fsS --max-time 2 "$DOCS_E2E_BASE_URL/introduction" -o /dev/null; then + break + fi + if [ "$attempt" -eq 60 ]; then + echo '::error::Documentation server did not become ready.' + exit 1 + fi + sleep 1 + done STATUS=0 - bun run --cwd apps/docs test:negotiation:e2e || STATUS=1 - bun run --cwd apps/docs scripts/test-docs-interfaces-e2e.ts || STATUS=1 - bun run --cwd apps/docs test:corpus:e2e || STATUS=1 - bun run --cwd apps/docs scripts/verify-api-samples.ts || STATUS=1 - bun --cwd apps/sim --no-env-file scripts/test-docs-search-provider-e2e.ts || STATUS=1 - bun run --cwd apps/docs test:navigation:e2e || STATUS=1 - DOCS_NAVIGATION_BROWSER=webkit DOCS_NAVIGATION_REPORT_PATH="$GITHUB_WORKSPACE/apps/docs/test-results/navigation-webkit/docs.json" bun run --cwd apps/docs test:navigation:e2e || STATUS=1 - exit "$STATUS" - - name: Package production preview for visual inspection - if: always() && inputs.preview_artifact && steps.build.outcome == 'success' - run: tar -czf docs-preview-build.tar.gz --exclude='.next/cache' -C apps/docs .next .content - - name: Upload production preview - if: always() && inputs.preview_artifact && steps.build.outcome == 'success' - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: docs-preview-build - path: docs-preview-build.tar.gz - retention-days: 3 - - name: Upload HTTP regression report + case "$DOCS_ACCEPTANCE_GROUP" in + http) + bun run --cwd apps/docs test:search:postgres:e2e || STATUS=1 + bun run --cwd apps/docs test:negotiation:e2e || STATUS=1 + bun run --cwd apps/docs scripts/test-docs-interfaces-e2e.ts || STATUS=1 + bun run --cwd apps/docs scripts/verify-api-samples.ts || STATUS=1 + bun --cwd apps/sim --no-env-file scripts/test-docs-search-provider-e2e.ts || STATUS=1 + ;; + platform) bun --cwd apps/sim --no-env-file scripts/test-docs-search-provider-e2e.ts || STATUS=1 ;; + corpus) bun run --cwd apps/docs test:corpus:e2e || STATUS=1 ;; + chromium|webkit) bun run --cwd apps/docs test:navigation:e2e || STATUS=1 ;; + *) echo "::error::Unknown documentation acceptance group: $DOCS_ACCEPTANCE_GROUP"; STATUS=1 ;; + esac + finish "$STATUS" + - name: Upload acceptance regression reports if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: docs-negotiation-report + name: docs-acceptance-${{ matrix.group }} path: apps/docs/test-results/ if-no-files-found: warn retention-days: 14 + + http: + if: always() && (github.event_name != 'workflow_dispatch' || !inputs.benchmark) + needs: [changes, build, source, acceptance] + name: docs HTTP + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require every documentation check to pass + env: + NEEDS: ${{ toJSON(needs) }} + run: | + if ! jq -e ' + keys == ["acceptance", "build", "changes", "source"] + and .changes.result == "success" + and ( + if .changes.outputs.docs == "true" then + (.changes.outputs.docs_site == "true" or .changes.outputs.docs_site == "false") + and (.changes.outputs.docs_platform == "true" or .changes.outputs.docs_platform == "false") + and (.changes.outputs.docs_site == "true" or .changes.outputs.docs_platform == "true") + and ([.build, .acceptance] | all(.result == "success")) + and (.source.result == (if .changes.outputs.docs_site == "true" then "success" else "skipped" end)) + elif .changes.outputs.docs == "false" then + .changes.outputs.docs_site == "false" + and .changes.outputs.docs_platform == "false" + and ([.build, .source, .acceptance] | all(.result == "skipped")) + else false + end + ) + ' <<< "$NEEDS"; then + echo '::error::Documentation checks did not all pass.' + exit 1 + fi diff --git a/apps/desktop/e2e-durations.json b/apps/desktop/e2e-durations.json index e82a13f8ac3..7d184cc16b2 100644 --- a/apps/desktop/e2e-durations.json +++ b/apps/desktop/e2e-durations.json @@ -1,21 +1,23 @@ { - "background-executor.spec.ts": 131.12, - "browser-chrome.spec.ts": 15.28, - "browser-focus.spec.ts": 6.53, - "browser-page-dialogs.spec.ts": 11.81, - "browser-tools.spec.ts": 94.66, - "chat-panel.spec.ts": 31.08, - "check-report.spec.ts": 3.78, - "computer-use.spec.ts": 2.07, - "continue-sign-in.spec.ts": 5.8, + "background-executor.spec.ts": 129.01, + "browser-chrome.spec.ts": 16.68, + "browser-focus.spec.ts": 14.91, + "browser-page-dialogs.spec.ts": 36.22, + "browser-page-parity.spec.ts": 3.89, + "browser-sign-in-prompts.spec.ts": 29.02, + "browser-tools.spec.ts": 136.49, + "chat-panel.spec.ts": 19.81, + "check-report.spec.ts": 2.48, + "computer-use.spec.ts": 3.62, + "continue-sign-in.spec.ts": 10.93, "desktop-tools-live-sim.spec.ts": 0.0, - "local-files.spec.ts": 27.68, + "local-files.spec.ts": 32.44, "packaged-smoke.spec.ts": 0.0, - "password-autofill.spec.ts": 46.56, - "session-cookies.spec.ts": 11.45, - "smoke.spec.ts": 25.56, - "source-connect.spec.ts": 41.49, - "terminal-cancel.spec.ts": 199.76, - "update-notification.spec.ts": 19.55, - "updater.spec.ts": 2.48 + "password-autofill.spec.ts": 40.27, + "session-cookies.spec.ts": 4.98, + "smoke.spec.ts": 28.65, + "source-connect.spec.ts": 40.84, + "terminal-cancel.spec.ts": 205.16, + "update-notification.spec.ts": 19.13, + "updater.spec.ts": 6.33 } diff --git a/apps/desktop/scripts/build-native.ts b/apps/desktop/scripts/build-native.ts index 8980683f31f..ea5c8af243e 100644 --- a/apps/desktop/scripts/build-native.ts +++ b/apps/desktop/scripts/build-native.ts @@ -1,17 +1,22 @@ import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, statSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { cpSync, mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { + nodeIncludeDirectory, + recordNativeBuild, + reuseNativeBuild, +} from '@desktop-scripts/native-build-cache' import { createLogger } from '@sim/logger' const logger = createLogger('DesktopNativeBuild') +const args = process.argv.slice(2) +if (args.some((argument) => argument !== '--computer-use') || args.length > 1) { + throw new Error('Usage: bun run scripts/build-native.ts [--computer-use]') +} if (process.platform !== 'darwin' && process.platform !== 'linux') { throw new Error('Native desktop modules require macOS or Linux.') } -const nodeExecutable = execFileSync('node', ['-p', 'process.execPath'], { encoding: 'utf8' }).trim() -const includeDirectory = join(dirname(nodeExecutable), '..', 'include', 'node') -if (!existsSync(join(includeDirectory, 'node_api.h'))) { - throw new Error(`Could not find Node-API headers in ${includeDirectory}`) -} +const includeDirectory = nodeIncludeDirectory() const outputDirectory = 'dist/native' mkdirSync(outputDirectory, { recursive: true }) const modules = [ @@ -22,13 +27,20 @@ const modules = [ ] for (const module of modules) { const output = join(outputDirectory, `${module.name}.node`) - if ( - existsSync(output) && - statSync(output).mtimeMs >= - Math.max(statSync(module.source).mtimeMs, statSync(import.meta.filename).mtimeMs) - ) - continue const macOS = process.platform === 'darwin' + const outputs = [{ path: output, executable: true }] + if (reuseNativeBuild(module.name, outputs)) { + try { + if (macOS) { + execFileSync('xcrun', ['lipo', output, '-verify_arch', 'arm64', 'x86_64']) + } + continue + } catch { + logger.warn('Rebuilding native module with invalid cached architectures', { + module: module.name, + }) + } + } execFileSync( macOS ? 'xcrun' : 'c++', [ @@ -58,5 +70,57 @@ for (const module of modules) { ], { stdio: 'inherit' } ) + recordNativeBuild(module.name, outputs) logger.info('Compiled native desktop module', { module: module.name }) } + +/** Bundle the native helper; packaged builds sign it with the desktop distribution identity. */ +function compileComputerUse(): void { + if (process.platform !== 'darwin') return + const bundle = 'dist/native/Sim Computer Use.app' + const binaryDirectory = join(bundle, 'Contents', 'MacOS') + const binary = join(binaryDirectory, 'SimComputerUse') + const outputs = [ + { path: binary, executable: true }, + { path: join(bundle, 'Contents', 'Info.plist') }, + { path: join(bundle, 'Contents', '_CodeSignature', 'CodeResources') }, + ] + if (reuseNativeBuild('computer-use', outputs)) { + try { + execFileSync('xcrun', ['lipo', binary, '-verify_arch', 'arm64', 'x86_64']) + execFileSync('codesign', ['--verify', '--strict', bundle]) + return + } catch { + rmSync(bundle, { recursive: true, force: true }) + } + } + rmSync(bundle, { recursive: true, force: true }) + mkdirSync(binaryDirectory, { recursive: true }) + cpSync('native/computer-use/Info.plist', join(bundle, 'Contents', 'Info.plist')) + const parts: string[] = [] + for (const arch of ['arm64', 'x86_64']) { + const output = join('dist/native', `computer-use-${arch}`) + execFileSync( + 'xcrun', + [ + 'swiftc', + '-parse-as-library', + '-O', + '-target', + `${arch}-apple-macosx14.0`, + 'native/computer-use/ComputerUse.swift', + '-o', + output, + ], + { stdio: 'inherit' } + ) + parts.push(output) + } + execFileSync('xcrun', ['lipo', '-create', ...parts, '-output', binary], { stdio: 'inherit' }) + for (const part of parts) rmSync(part) + execFileSync('codesign', ['--force', '--sign', '-', bundle], { stdio: 'inherit' }) + recordNativeBuild('computer-use', outputs) + logger.info('Compiled native macOS Computer Use helper') +} + +if (args.includes('--computer-use')) compileComputerUse() diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts index 73dff48279f..d8c74efdf14 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -34,42 +34,6 @@ rmSync(generatedIcon, { force: true, recursive: true }) cpSync(appIcon, generatedIcon, { recursive: true }) console.log(`• Selecting desktop icon: ${appIcon}`) -/** Bundle the native helper; packaged builds sign it with the desktop distribution identity. */ -function compileComputerUse(): void { - if (process.platform !== 'darwin') return - const bundle = 'dist/native/Sim Computer Use.app' - const binaryDirectory = join(bundle, 'Contents', 'MacOS') - mkdirSync(binaryDirectory, { recursive: true }) - cpSync('native/computer-use/Info.plist', join(bundle, 'Contents', 'Info.plist')) - const parts: string[] = [] - for (const arch of ['arm64', 'x86_64']) { - const output = join('dist/native', `computer-use-${arch}`) - execFileSync( - 'xcrun', - [ - 'swiftc', - '-parse-as-library', - '-O', - '-target', - `${arch}-apple-macosx14.0`, - 'native/computer-use/ComputerUse.swift', - '-o', - output, - ], - { stdio: 'inherit' } - ) - parts.push(output) - } - execFileSync( - 'xcrun', - ['lipo', '-create', ...parts, '-output', join(binaryDirectory, 'SimComputerUse')], - { stdio: 'inherit' } - ) - for (const part of parts) rmSync(part) - execFileSync('codesign', ['--force', '--sign', '-', bundle], { stdio: 'inherit' }) - console.log('• Compiled native macOS Computer Use helper') -} - const common = { bundle: true, platform: 'node' as const, @@ -131,8 +95,9 @@ const renderer: BuildOptions = { } async function run(): Promise { - execFileSync(process.execPath, ['run', 'scripts/build-native.ts'], { stdio: 'inherit' }) - compileComputerUse() + execFileSync(process.execPath, ['run', 'scripts/build-native.ts', '--computer-use'], { + stdio: 'inherit', + }) mkdirSync('dist/renderer', { recursive: true }) for (const font of ['geist-variable.woff2', 'geist-mono-variable.woff2']) { cpSync(resolve('../../packages/auth-ui/src/assets', font), join('dist/renderer', font)) diff --git a/apps/desktop/scripts/native-build-cache.ts b/apps/desktop/scripts/native-build-cache.ts new file mode 100644 index 00000000000..d42ed8db280 --- /dev/null +++ b/apps/desktop/scripts/native-build-cache.ts @@ -0,0 +1,127 @@ +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { accessSync, constants, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { isRecordLike } from '@sim/utils/object' +import { compareStrings } from '@sim/utils/string' + +interface NativeOutput { + path: string + executable?: boolean +} + +const SOURCES = [ + 'native/directory.cc', + 'native/help-search.mm', + 'native/computer-use/ComputerUse.swift', + 'native/computer-use/Info.plist', + 'scripts/build.ts', + 'scripts/build-native.ts', + 'scripts/native-build-cache.ts', + 'tsconfig.json', +] as const + +let fingerprint: string | undefined + +function command(executable: string, args: string[]): string { + return execFileSync(executable, args, { encoding: 'utf8' }).trim() +} + +/** Resolves the headers used by the compiler rather than the runtime executing Bun. */ +export function nodeIncludeDirectory(): string { + const executable = command('node', ['-p', 'process.execPath']) + const directory = join(dirname(executable), '..', 'include', 'node') + accessSync(join(directory, 'node_api.h'), constants.R_OK) + return directory +} + +/** Keys native outputs by all source, compiler, SDK, target and Node-header inputs. */ +function nativeBuildFingerprint(): string { + if (fingerprint) return fingerprint + const hash = createHash('sha256') + const add = (name: string, value: string | Buffer) => { + hash.update(`${name}\0${value.length}\0`) + hash.update(value) + } + add('platform', process.platform) + add('architecture', process.arch) + for (const source of SOURCES) add(source, readFileSync(source)) + const addHeaders = (directory: string, prefix = '') => { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => + compareStrings(a.name, b.name) + )) { + const path = join(directory, entry.name) + const name = join(prefix, entry.name) + if (entry.isDirectory()) addHeaders(path, name) + else if (entry.isFile()) add(name, readFileSync(path)) + } + } + addHeaders(nodeIncludeDirectory()) + if (process.platform === 'darwin') { + add('os', command('sw_vers', ['-buildVersion'])) + add('developer', command('xcode-select', ['-p'])) + for (const args of [ + ['clang++', '--version'], + ['swiftc', '--version'], + ['--show-sdk-path'], + ['--show-sdk-version'], + ['--show-sdk-build-version'], + ]) { + add(args.join(' '), command('xcrun', args)) + } + } else { + add('compiler', command('c++', ['--version'])) + } + for (const name of [ + 'DEVELOPER_DIR', + 'SDKROOT', + 'MACOSX_DEPLOYMENT_TARGET', + 'CPATH', + 'CPLUS_INCLUDE_PATH', + 'TOOLCHAINS', + ]) { + add(name, process.env[name] ?? '') + } + fingerprint = hash.digest('hex') + return fingerprint +} + +function digest(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +/** A restore is reusable only when its current inputs, bytes and executable modes match. */ +export function reuseNativeBuild(name: string, outputs: readonly NativeOutput[]): boolean { + const expected = nativeBuildFingerprint() + try { + const manifest: unknown = JSON.parse(readFileSync(`dist/native/.${name}.json`, 'utf8')) + if ( + !isRecordLike(manifest) || + manifest.fingerprint !== expected || + !isRecordLike(manifest.outputs) + ) { + return false + } + for (const output of outputs) { + if (manifest.outputs[output.path] !== digest(output.path)) return false + if (output.executable) accessSync(output.path, constants.X_OK) + } + return true + } catch { + return false + } +} + +/** Records only completed native builds; incomplete or corrupt outputs are rebuilt. */ +export function recordNativeBuild(name: string, outputs: readonly NativeOutput[]): void { + mkdirSync('dist/native', { recursive: true }) + writeFileSync( + `dist/native/.${name}.json`, + `${JSON.stringify({ + fingerprint: nativeBuildFingerprint(), + outputs: Object.fromEntries(outputs.map((output) => [output.path, digest(output.path)])), + })}\n` + ) +} + +if (import.meta.main) process.stdout.write(`${nativeBuildFingerprint()}\n`) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 3f6edb1844f..33aef86669b 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@desktop-scripts/*": ["./scripts/*"] }, "jsx": "react-jsx" }, diff --git a/apps/desktop/turbo.json b/apps/desktop/turbo.json index 975eab257e2..89edf704205 100644 --- a/apps/desktop/turbo.json +++ b/apps/desktop/turbo.json @@ -1,6 +1,9 @@ { "extends": ["//"], "tasks": { + "ci:runtime": { + "inputs": ["$TURBO_EXTENDS$", "!e2e-durations.json"] + }, "build": { "inputs": [ "$TURBO_DEFAULT$", diff --git a/apps/docs/package.json b/apps/docs/package.json index 12c471f9ac2..634cfa02fb0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -7,7 +7,7 @@ "dev": "bun run dev:cache:cap && bun run content:generate && bunx concurrently --kill-others --names Next,Content \"next dev --port 3001\" \"bun scripts/compile-content.ts --watch\"", "dev:cache:cap": "bun run ../../scripts/prune-turbopack-cache.ts", "dev:clean": "rm -rf .next/dev/cache", - "build": "bun scripts/fetch-git-history.ts && bun run content:generate && NODE_OPTIONS='--max-old-space-size=8192' next build", + "build": "bun scripts/fetch-git-history.ts && bun run content:generate --prune && NODE_OPTIONS='--max-old-space-size=8192' next build", "start": "next start", "test": "vitest run", "test:negotiation:e2e": "bun scripts/test-negotiation-e2e.ts", diff --git a/apps/docs/scripts/compile-content.ts b/apps/docs/scripts/compile-content.ts index 63981768c26..cf3601b0f61 100644 --- a/apps/docs/scripts/compile-content.ts +++ b/apps/docs/scripts/compile-content.ts @@ -1,7 +1,7 @@ import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync, watch } from 'node:fs' -import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { createLogger } from '@sim/logger' import { compareStrings, escapeRegExp } from '@sim/utils/string' @@ -224,11 +224,13 @@ async function generate() { const sourceHash = hash(raw) const stem = hash(`${relative}:${sourceHash}:${pipelineHash}`).slice(0, 24) const javascriptPath = path.join(OUTPUT_ROOT, 'pages', `${stem}.js`) + const declarationPath = javascriptPath.replace(/\.js$/, '.d.ts') const prior = previous.files[relative] if ( previous.pipeline === pipelineHash && prior?.hash === sourceHash && - existsSync(javascriptPath) + existsSync(javascriptPath) && + existsSync(declarationPath) ) { validateMedia(relative, prior.media) const { lastModified: _previousDate, ...content } = prior.page @@ -269,7 +271,7 @@ async function generate() { validateMedia(relative, result.media) await writeFile(javascriptPath, result.javascript) await writeFile( - javascriptPath.replace(/\.js$/, '.d.ts'), + declarationPath, "import type { MDXContent } from 'mdx/types'\ndeclare const Content: MDXContent\nexport default Content\n" ) cache.files[relative] = { @@ -402,6 +404,16 @@ async function generate() { await writeFile(path.join(OUTPUT_ROOT, `${name}.tmp`), data) await rename(path.join(OUTPUT_ROOT, `${name}.tmp`), path.join(OUTPUT_ROOT, name)) } + if (process.argv.includes('--prune')) { + const currentModules = new Set( + Object.entries(cache.files).flatMap(([source, entry]) => { + const stem = hash(`${source}:${entry.hash}:${pipelineHash}`).slice(0, 24) + return [`${stem}.js`, `${stem}.d.ts`] + }) + ) + for (const file of await readdir(path.join(OUTPUT_ROOT, 'pages'))) + if (!currentModules.has(file)) await rm(path.join(OUTPUT_ROOT, 'pages', file)) + } logger.info( `Catalog ready: ${pages.length} pages (${compiled} recompiled), ${Buffer.byteLength(serialized)} bytes` ) diff --git a/apps/docs/scripts/test-generators-e2e.ts b/apps/docs/scripts/test-generators-e2e.ts index 2ec7a702e83..e0226d27b6b 100644 --- a/apps/docs/scripts/test-generators-e2e.ts +++ b/apps/docs/scripts/test-generators-e2e.ts @@ -1,14 +1,17 @@ import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { access, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { compareStrings } from '@sim/utils/string' +import type { ContentCatalog } from '@/lib/content/types' const logger = createLogger('DocsGeneratorsE2E') -const reportPath = process.env.DOCS_GENERATORS_REPORT_PATH -if (!reportPath || process.env.CI !== 'true') +const reportSetting = process.env.DOCS_GENERATORS_REPORT_PATH +if (!reportSetting || process.env.CI !== 'true') throw new Error('Run in an isolated CI checkout with DOCS_GENERATORS_REPORT_PATH configured') +const reportPath: string = reportSetting const root = resolve(import.meta.dirname, '../../..') const startedAt = new Date().toISOString() const commands = [ @@ -33,8 +36,40 @@ const git = (...args: string[]) => const hash = (value: string) => createHash('sha256').update(value).digest('hex') const changedFiles: string[][] = [] let failure: string | undefined +const fixturePath = resolve(root, 'apps/docs/content/docs/ci-cache-fixture.mdx') +let createdFixture = false await mkdir(dirname(reportPath), { recursive: true }) +async function runGenerator(command: string, logName: string) { + const output = execFileSync( + 'bun', + [command, ...(command === 'apps/docs/scripts/compile-content.ts' ? ['--prune'] : [])], + { + cwd: root, + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, DATABASE_URL: '', OPENAI_API_KEY: '' }, + } + ) + await writeFile(resolve(dirname(reportPath), logName), output) +} + +async function contentArtifacts() { + const artifacts: Record = {} + for (const file of ['catalog.json', 'search.json', 'routes.json', 'registry.ts', 'cache.json']) + artifacts[file] = hash(await readFile(resolve(root, 'apps/docs/.content', file), 'utf8')) + artifacts.fullMarkdown = hash( + await readFile(resolve(root, 'apps/docs/public/llms-full.txt'), 'utf8') + ) + const modules = createHash('sha256') + const directory = resolve(root, 'apps/docs/.content/pages') + for (const file of (await readdir(directory)).sort(compareStrings)) + modules.update(`${file}\0`).update(await readFile(resolve(directory, file))) + artifacts.modules = modules.digest('hex') + return artifacts +} + async function authoredRegions() { const regions: Record = {} const files = git('ls-files', 'apps/docs/content/docs/**/*.mdx').trim().split('\n') @@ -69,20 +104,28 @@ try { throw new Error('Generator verification requires a clean checkout') const regionsBefore = await authoredRegions() let firstArtifacts: Record | undefined + let missingDeclaration: string | undefined for (const pass of [1, 2]) { - for (const command of commands) - await check(`Pass ${pass}: ${command}`, async () => { - const output = execFileSync('bun', [command], { - cwd: root, - encoding: 'utf8', - timeout: 180_000, - maxBuffer: 16 * 1024 * 1024, - env: { ...process.env, DATABASE_URL: '', OPENAI_API_KEY: '' }, - }) + if (pass === 2) { + const directory = resolve(root, 'apps/docs/.content/pages') + const declaration = (await readdir(directory)).find((file) => file.endsWith('.d.ts')) + if (!declaration) throw new Error('No compiled declaration is available for recovery') + missingDeclaration = resolve(directory, declaration) + await rm(missingDeclaration) + for (const suffix of ['js', 'd.ts']) await writeFile( - resolve(dirname(reportPath), `pass-${pass}-${command.split('/').at(-1)}.log`), - output + resolve(root, 'apps/docs/.content/pages', `obsolete-page.${suffix}`), + 'obsolete compiled page' ) + } + for (const command of commands) + await check(`Pass ${pass}: ${command}`, async () => { + await runGenerator(command, `pass-${pass}-${command.split('/').at(-1)}.log`) + }) + if (missingDeclaration) + await check('A partial content cache regenerates its missing declaration', async () => { + if (!missingDeclaration) throw new Error('The missing declaration fixture is unavailable') + await access(missingDeclaration) }) const diff = git('diff', '--binary') changedFiles.push([ @@ -95,12 +138,7 @@ try { for (const [name, digest] of Object.entries(regionsBefore)) if (regionsAfter[name] !== digest) throw new Error(`Authored region changed: ${name}`) }) - const artifacts: Record = {} - for (const file of ['catalog.json', 'search.json', 'routes.json', 'registry.ts', 'cache.json']) - artifacts[file] = hash(await readFile(resolve(root, 'apps/docs/.content', file), 'utf8')) - artifacts.fullMarkdown = hash( - await readFile(resolve(root, 'apps/docs/public/llms-full.txt'), 'utf8') - ) + const artifacts = await contentArtifacts() const previousArtifacts = firstArtifacts if (previousArtifacts) await check('Unchanged second content generation is byte-identical', async () => { @@ -109,15 +147,72 @@ try { }) else firstArtifacts = artifacts } + await check('Content generation removes obsolete cached page modules', async () => { + for (const suffix of ['js', 'd.ts']) { + const obsoletePage = resolve(root, 'apps/docs/.content/pages', `obsolete-page.${suffix}`) + if ( + await access(obsoletePage).then( + () => true, + () => false + ) + ) + throw new Error(`Obsolete compiled page remains: ${obsoletePage}`) + } + }) await check('Generated source is current on both passes', async () => { if (changedFiles.some((files) => files.length)) throw new Error(`Unexpected generated changes: ${JSON.stringify(changedFiles)}`) }) + const originalModules = (await readdir(resolve(root, 'apps/docs/.content/pages'))).sort( + compareStrings + ) + const readCatalog = async () => + JSON.parse( + await readFile(resolve(root, 'apps/docs/.content/catalog.json'), 'utf8') + ) as ContentCatalog + const fixture = (version: string) => + `---\ntitle: CI cache fixture\ndescription: Incremental compilation fixture\n---\n\n${version}\n` + await check('New authored content is published from a warm cache', async () => { + if ( + await access(fixturePath).then( + () => true, + () => false + ) + ) + throw new Error('The incremental compilation fixture already exists') + createdFixture = true + await writeFile(fixturePath, fixture('First fixture revision.')) + await runGenerator('apps/docs/scripts/compile-content.ts', 'cache-create.log') + const page = (await readCatalog()).pages.find((page) => page.id === '/ci-cache-fixture') + if (!page?.markdown.includes('First fixture revision.')) + throw new Error('The new authored page was not published') + }) + await check('Changed authored content invalidates its cached output', async () => { + await writeFile(fixturePath, fixture('Second fixture revision.')) + await runGenerator('apps/docs/scripts/compile-content.ts', 'cache-edit.log') + const page = (await readCatalog()).pages.find((page) => page.id === '/ci-cache-fixture') + if ( + !page?.markdown.includes('Second fixture revision.') || + page.markdown.includes('First fixture revision.') + ) + throw new Error('The old authored content survived recompilation') + }) + await check('Deleted authored content leaves no catalog entry or cached modules', async () => { + await rm(fixturePath) + await runGenerator('apps/docs/scripts/compile-content.ts', 'cache-delete.log') + const modules = (await readdir(resolve(root, 'apps/docs/.content/pages'))).sort(compareStrings) + if (JSON.stringify(modules) !== JSON.stringify(originalModules)) + throw new Error('The compiled module inventory changed after the fixture was removed') + const restored = await contentArtifacts() + if (JSON.stringify(restored) !== JSON.stringify(firstArtifacts)) + throw new Error('Deleting the fixture did not restore the original generated artifacts') + }) } catch (error) { failure = getErrorMessage(error) logger.error('Generator verification failed', { error: failure }) process.exitCode = 1 } finally { + if (createdFixture) await rm(fixturePath, { force: true }) await writeFile( reportPath, `${JSON.stringify({ startedAt, completedAt: new Date().toISOString(), revision: git('rev-parse', 'HEAD').trim(), status: failure ? 'failed' : 'passed', failure, changedFiles, results }, null, 2)}\n` diff --git a/apps/sim/lib/mothership/agent-cli/services.test.ts b/apps/sim/lib/mothership/agent-cli/services.test.ts index 9a98fd6c622..e8e67a62349 100644 --- a/apps/sim/lib/mothership/agent-cli/services.test.ts +++ b/apps/sim/lib/mothership/agent-cli/services.test.ts @@ -32,6 +32,10 @@ import { executeAgentCliService } from '@/lib/mothership/agent-cli/services' import type { AgentCliRequest } from '@/lib/mothership/generated/agent-cli' import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli' +const { routeExecution: actualRouteExecution } = await vi.importActual< + typeof import('@/lib/mothership/tools/server/router') +>('@/lib/mothership/tools/server/router') + const boundary = { ...hoisted, workspace: mothershipWorkspaceTargetMockFns.mockResolveInvocationWorkspace, @@ -200,10 +204,7 @@ describe('scoped CLI service adapter', () => { ] as const)( 'returns actionable canonical %s input errors through the real router', async (name, input, field, constraint) => { - const { routeExecution } = await vi.importActual< - typeof import('@/lib/mothership/tools/server/router') - >('@/lib/mothership/tools/server/router') - boundary.route.mockImplementationOnce(routeExecution) + boundary.route.mockImplementationOnce(actualRouteExecution) const result = await executeSimCli( { request: service(name, input) }, { ...organization, workflowId: '' } diff --git a/apps/sim/package.json b/apps/sim/package.json index afbc2dc9748..3e177d245f8 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -51,6 +51,9 @@ "ci:e2e-cli": "bash ../../.github/scripts/http-e2e.sh cli", "ci:e2e-stop-after": "bash ../../.github/scripts/http-e2e.sh stop-after", "ci:e2e-desktop-inbox": "bash ../../.github/scripts/http-e2e.sh desktop-inbox", + "ci:e2e-assistant-actions": "bash ../../.github/scripts/http-e2e.sh assistant-actions", + "ci:e2e-organization-search": "bash ../../.github/scripts/http-e2e.sh organization-search", + "ci:e2e-docs-platform": "bun --no-env-file scripts/test-docs-search-provider-e2e.ts", "ci:e2e-project-files": "bash ../../.github/scripts/http-e2e.sh project-files", "ci:e2e-mobile": "bash ../../.github/scripts/http-e2e.sh mobile", "ci:e2e-seo": "bash ../../.github/scripts/http-e2e.sh seo", diff --git a/apps/sim/scripts/test-organization-search-e2e.ts b/apps/sim/scripts/test-organization-search-e2e.ts index 02a7a946042..0e5a9666a93 100644 --- a/apps/sim/scripts/test-organization-search-e2e.ts +++ b/apps/sim/scripts/test-organization-search-e2e.ts @@ -219,10 +219,12 @@ async function check(name: string, run: () => Promise) { } } -async function capture(name: string) { - await page.evaluate(() => document.fonts.ready) - await page.addStyleTag({ content: 'nextjs-portal { display: none; }' }) - await page.screenshot({ path: resolve(artifactDirectory, `${name}.png`) }) +async function capture(name: string, timeout = 10_000) { + await page.screenshot({ + path: resolve(artifactDirectory, `${name}.png`), + style: 'nextjs-portal { display: none; }', + timeout, + }) } async function verifyLegacyDraft( @@ -1012,28 +1014,37 @@ try { }) } catch (error) { logger.error('Organization Search E2E failed', { error: getErrorMessage(error) }) - await capture('failure').catch(() => {}) process.exitCode = 1 + await capture('failure', 5000).catch(() => {}) } finally { currentCheck = 'Teardown' - await browser.close() - try { - await sql.begin(async (tx) => { - await tx`delete from workspace where owner_id = ${userId}` - await tx`delete from project where owner_id = ${userId}` - await tx`delete from organization where id in (${organizationId}, ${otherOrganizationId})` - await tx`delete from "user" where id = ${userId}` - }) - } catch (error) { - checks.push({ - name: 'Fixture cleanup', - status: 'failed', - durationMs: 0, - error: getErrorMessage(error), - }) - process.exitCode = 1 - } finally { - await sql.end() + for (const [name, cleanup] of [ + ['Browser cleanup', () => browser.close()], + [ + 'Fixture cleanup', + async () => { + await sql.begin(async (tx) => { + await tx`delete from workspace where owner_id = ${userId}` + await tx`delete from project where owner_id = ${userId}` + await tx`delete from organization where id in (${organizationId}, ${otherOrganizationId})` + await tx`delete from "user" where id = ${userId}` + }) + }, + ], + ['Database connection cleanup', () => sql.end({ timeout: 5 })], + ] as const) { + const started = performance.now() + try { + await cleanup() + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: getErrorMessage(error), + }) + process.exitCode = 1 + } } await mkdir(dirname(reportPath), { recursive: true }) await writeFile( diff --git a/apps/sim/turbo.json b/apps/sim/turbo.json index 741235ccd8a..ba369fbf57d 100644 --- a/apps/sim/turbo.json +++ b/apps/sim/turbo.json @@ -27,6 +27,7 @@ "!scripts/test-desktop-inbox-e2e.ts", "!scripts/test-assistant-actions-e2e.ts", "!scripts/test-organization-search-e2e.ts", + "!scripts/test-docs-search-provider-e2e.ts", "!scripts/test-file-*-e2e.ts", "!scripts/test-landing-seo-e2e.ts", "!scripts/test-mcp-host-e2e.ts", @@ -119,12 +120,23 @@ "cache": false }, "ci:e2e-desktop-inbox": { + "dependsOn": ["ci:server", "@sim/realtime#ci:runtime"], + "inputs": ["scripts/test-desktop-inbox-e2e.ts"], + "cache": false + }, + "ci:e2e-assistant-actions": { "dependsOn": ["ci:server", "ci:ui", "@sim/realtime#ci:runtime"], - "inputs": [ - "scripts/test-desktop-inbox-e2e.ts", - "scripts/test-assistant-actions-e2e.ts", - "scripts/test-organization-search-e2e.ts" - ], + "inputs": ["scripts/test-assistant-actions-e2e.ts"], + "cache": false + }, + "ci:e2e-organization-search": { + "dependsOn": ["ci:server", "ci:ui", "@sim/realtime#ci:runtime"], + "inputs": ["scripts/test-organization-search-e2e.ts"], + "cache": false + }, + "ci:e2e-docs-platform": { + "dependsOn": ["ci:server", "docs#build"], + "inputs": ["scripts/test-docs-search-provider-e2e.ts"], "cache": false }, "ci:e2e-project-files": { @@ -145,7 +157,8 @@ "ci:desktop-live": { "dependsOn": ["ci:server", "ci:ui", "@sim/desktop#ci:runtime", "@sim/realtime#ci:runtime"], "inputs": [ - "$TURBO_ROOT$/apps/desktop/e2e/**", + "$TURBO_ROOT$/apps/desktop/e2e/desktop-tools-live-sim.spec.ts", + "$TURBO_ROOT$/apps/desktop/e2e/fixtures/**", "$TURBO_ROOT$/apps/desktop/playwright.config.ts" ], "cache": false @@ -158,6 +171,7 @@ "!scripts/test-desktop-inbox-e2e.ts", "!scripts/test-assistant-actions-e2e.ts", "!scripts/test-organization-search-e2e.ts", + "!scripts/test-docs-search-provider-e2e.ts", "!scripts/test-file-*-e2e.ts", "!scripts/test-landing-seo-e2e.ts", "!scripts/test-mcp-host-e2e.ts", diff --git a/scripts/ci-changes.test.ts b/scripts/ci-changes.test.ts index fb48aa93ff1..09292fac4b5 100644 --- a/scripts/ci-changes.test.ts +++ b/scripts/ci-changes.test.ts @@ -15,6 +15,7 @@ interface Plan { production: { group: string }[] typecheckPackages: string[] testPackages: string[] + docsGroups: string[] } function git(cwd: string, ...args: string[]) { const result = spawnSync('git', args, { cwd, encoding: 'utf8' }) @@ -48,12 +49,18 @@ function fixture() { ['apps/realtime', '@sim/realtime', { '@sim/auth': 'workspace:*' }], ['apps/desktop', '@sim/desktop', {}], ['apps/extension', '@sim/extension', {}], - ['apps/docs', 'docs', {}], + ['apps/docs', 'docs', { '@sim/workflow-renderer': 'workspace:*' }], ['packages/auth', '@sim/auth', { '@sim/security': 'workspace:*' }], ['packages/security', '@sim/security', {}], ['packages/db', '@sim/db', {}], ['packages/ts-sdk', 'simstudio-ts-sdk', {}], ['packages/sim-cli', 'sim', {}], + [ + 'packages/workflow-renderer', + '@sim/workflow-renderer', + { '@sim/workflow-types': 'workspace:*' }, + ], + ['packages/workflow-types', '@sim/workflow-types', {}], ] as const const appScripts = JSON.parse( readFileSync(resolve(root, 'apps/sim/package.json'), 'utf8') @@ -84,6 +91,7 @@ function fixture() { for (const file of [ 'turbo.json', 'apps/sim/turbo.json', + 'apps/desktop/turbo.json', '.github/ci-suites.json', '.github/scripts/ci-plan.gql', ]) { @@ -138,6 +146,7 @@ describe('CI task attribution through real Turborepo', () => { build: false, desktop_live: false, e2e_build: false, + docs: false, }) }) it('selects integration and browser checks for workspace code shared with desktop execution', () => { @@ -149,7 +158,6 @@ describe('CI task attribution through real Turborepo', () => { 'assistant-actions', 'browser-extension-access', 'browser-extension-native', - 'desktop-inbox', 'mobile', 'organization-search', 'seo', @@ -191,9 +199,53 @@ describe('CI task attribution through real Turborepo', () => { build: false, integration: false, desktop_live: false, + docs: true, }) expect(plan.testPackages).toEqual(['docs']) }) + it('selects docs acceptance for a transitive rendering dependency', () => { + const plan = classifyFiles(['packages/workflow-types/src/index.ts']) + expect(plan.fallback).toBeUndefined() + expect(plan.flags.docs).toBe(true) + expect(plan.docsGroups).toEqual(['http', 'corpus', 'chromium', 'webkit']) + }) + it('keeps docs unit-only edits out of production acceptance', () => { + const plan = classifyFiles(['apps/docs/lib/content/links.test.ts']) + expect(plan.fallback).toBeUndefined() + expect(plan.flags.docs).toBe(false) + expect(plan.testPackages).toEqual(['docs']) + }) + it('selects docs acceptance for its standalone browser harness', () => { + const plan = classifyFiles(['apps/docs/scripts/test-navigation-e2e.ts']) + expect(plan.fallback).toBeUndefined() + expect(plan.flags.docs).toBe(true) + }) + it('selects only the platform docs boundary for a consumer harness change', () => { + const plan = classifyFiles(['apps/sim/scripts/test-docs-search-provider-e2e.ts']) + expect(plan.fallback).toBeUndefined() + expect(groups(plan)).toEqual([]) + expect(plan.docsGroups).toEqual(['platform']) + expect(plan.flags).toMatchObject({ + docs: true, + docs_site: false, + docs_platform: true, + test_app: false, + e2e_build: false, + integration: false, + desktop_live: false, + }) + }) + it('selects platform docs for backend changes without site browser checks', () => { + const plan = classifyFiles(['apps/sim/lib/mothership/docs/search.ts']) + expect(plan.fallback).toBeUndefined() + expect(plan.flags).toMatchObject({ docs: true, docs_site: false, docs_platform: true }) + expect(plan.docsGroups).toEqual(['platform']) + }) + it('keeps desktop smoke weights out of live runtime attribution', () => { + const plan = classifyFiles(['apps/desktop/e2e-durations.json']) + expect(plan.fallback).toBeUndefined() + expect(plan.flags).toMatchObject({ desktop_live: false, e2e_build: false }) + }) it('does not build or run E2E for an app unit-test-only edit', () => { const plan = classifyFiles(['apps/sim/lib/auth/session.test.ts']) expect(plan.fallback).toBeUndefined() @@ -222,13 +274,13 @@ describe('CI task attribution through real Turborepo', () => { expect(plan.flags.integration).toBe(true) }) it.each([ - 'test-desktop-inbox-e2e.ts', - 'test-assistant-actions-e2e.ts', - 'test-organization-search-e2e.ts', - ])('selects every isolated organization acceptance job for %s', (file) => { + ['test-desktop-inbox-e2e.ts', 'desktop-inbox'], + ['test-assistant-actions-e2e.ts', 'assistant-actions'], + ['test-organization-search-e2e.ts', 'organization-search'], + ])('selects the owning organization acceptance job for %s', (file, group) => { const plan = classifyFiles([`apps/sim/scripts/${file}`]) expect(plan.fallback).toBeUndefined() - expect(groups(plan)).toEqual(['assistant-actions', 'desktop-inbox', 'organization-search']) + expect(groups(plan)).toEqual([group]) expect(plan.flags).toMatchObject({ http: true, production: false, @@ -349,6 +401,26 @@ describe('CI task attribution through real Turborepo', () => { }) expect(plan.testPackages).toEqual(['@sim/desktop']) }) + it('keeps shell-only smoke edits out of the live Sim execution gate', () => { + const plan = classifyFiles(['apps/desktop/e2e/browser-tools.spec.ts']) + expect(plan.fallback).toBeUndefined() + expect(groups(plan)).toEqual([]) + expect(plan.flags).toMatchObject({ + desktop_live: false, + e2e_build: false, + integration: false, + build: false, + }) + }) + it.each([ + 'apps/desktop/e2e/desktop-tools-live-sim.spec.ts', + 'apps/desktop/e2e/fixtures/live-sim.ts', + 'apps/desktop/playwright.config.ts', + ])('keeps live execution coverage when its harness changes: %s', (file) => { + const plan = classifyFiles([file]) + expect(plan.fallback).toBeUndefined() + expect(plan.flags).toMatchObject({ desktop_live: true, e2e_build: true }) + }) it('runs everything if the real task query cannot parse configuration', () => { const { repo, base } = fixture() write(repo, 'turbo.json', '{invalid') diff --git a/scripts/ci-plan.ts b/scripts/ci-plan.ts index 65885d716a7..4a0df81507c 100644 --- a/scripts/ci-plan.ts +++ b/scripts/ci-plan.ts @@ -98,6 +98,13 @@ const http = suites.http.filter((suite) => affected(suite.task)) const production = suites.production.filter((suite) => affected(suite.task)) const desktopLive = affected('ci:desktop-live') const appTest = affected('test') +const docsSite = fallback !== undefined || selected.has('docs#build') +const docsPlatform = affected('ci:e2e-docs-platform') +const docsGroups = docsSite + ? ['http', 'corpus', 'chromium', 'webkit'] + : docsPlatform + ? ['platform'] + : [] function taskPackages(name: string, omitApp = false): string[] { return fallback ? ['*'] @@ -112,6 +119,9 @@ function taskPackages(name: string, omitApp = false): string[] { const packages = taskPackages('test', true) const typecheckPackages = taskPackages('type-check') const flags = { + docs: docsSite || docsPlatform, + docs_site: docsSite, + docs_platform: docsPlatform, integration: affected('ci:integration'), http: http.length > 0, production: production.length > 0, @@ -128,6 +138,7 @@ const outputs = { test_matrix: JSON.stringify({ shard: appTest ? [1, 2] : [1] }), test_packages: JSON.stringify(packages), typecheck_packages: JSON.stringify(typecheckPackages), + docs_matrix: JSON.stringify({ include: docsGroups.map((group) => ({ group })) }), } const report = { base, @@ -140,6 +151,7 @@ const report = { production, testPackages: packages, typecheckPackages, + docsGroups, } if (process.env.CI_PLAN_PATH) writeFileSync(process.env.CI_PLAN_PATH, `${JSON.stringify(report, null, 2)}\n`) diff --git a/scripts/create-single-release.test.ts b/scripts/create-single-release.test.ts index 87fe00cffd1..01919b31fd8 100644 --- a/scripts/create-single-release.test.ts +++ b/scripts/create-single-release.test.ts @@ -77,8 +77,8 @@ describe('release commit lookup', () => { }) it('finds the release boundaries when older history exceeds the subprocess buffer', () => { - for (let index = 0; index < 40; index++) { - commit(`chore: historical change ${index} ${'x'.repeat(32_000)}`) + for (let index = 0; index < 4; index++) { + commit(`chore: historical change ${index} ${'x'.repeat(320_000)}`) } const previous = commit('v0.8.30: previous release') commit('fix(search): improve indexing (#7720)')