diff --git a/.agents/skills/cleanup/SKILL.md b/.agents/skills/cleanup/SKILL.md index 4a286d701ed..ddd4aa7ef9c 100644 --- a/.agents/skills/cleanup/SKILL.md +++ b/.agents/skills/cleanup/SKILL.md @@ -1,6 +1,6 @@ --- name: cleanup -description: Run all code quality skills — effects, memo, callbacks, state, React Query, emcn design review, url-state, and comments — analyzing in parallel, then applying fixes sequentially +description: Run all code quality skills — effects, memo, callbacks, state, React Query, emcn design review, url-state, comments, and test-audit — analyzing in parallel, then applying fixes sequentially argument-hint: "[scope] [fix=true|false]" --- @@ -16,9 +16,9 @@ User arguments: $ARGUMENTS Parse `$ARGUMENTS` into `scope` and `fix`: extract the `fix=true|false` token wherever it appears in the string and strip it from `scope`; defaults are the current changes and `fix=true`. `fix` is consumed by Step 3 only — the passes below always run `fix=false`. -Spawn all eight passes concurrently as subagents in a **single message** (multiple Agent tool calls). Each runs its skill on the parsed `scope` with `fix=false` — analysis and proposals ONLY, no edits. Instruct each agent to return its findings as a structured list: for every proposed change, the file path, line range, a one-line description of the change, and the exact before/after so the orchestrator can apply it without re-deriving. +Spawn all nine passes concurrently as subagents in a **single message** (multiple Agent tool calls). Each runs its skill on the parsed `scope` with `fix=false` — analysis and proposals ONLY, no edits. Instruct each agent to return its findings as a structured list: for every proposed change, the file path, line range, a one-line description of the change, and the exact before/after so the orchestrator can apply it without re-deriving. -Run these eight in parallel on the parsed `scope`: +Run these nine in parallel on the parsed `scope`: 1. `/you-might-not-need-an-effect fix=false` 2. `/you-might-not-need-a-memo fix=false` @@ -28,10 +28,11 @@ Run these eight in parallel on the parsed `scope`: 6. `/emcn-design-review fix=false` 7. `/you-might-not-need-url-state fix=false` 8. `/you-might-not-need-a-comment fix=false` +9. `/test-audit audit ` — read-only; only when the scope adds or changes test files (`*.test.ts(x)`, `*.integration.ts`, `e2e/**`). It applies the authoring gate to every new or changed test and proposes deleting the ones that fail it. ## Step 2 — Converge -Collect all findings into one list, **keeping each proposal tagged with the pass that produced it** — do NOT collapse a file's proposals into a single unlabeled patch, because Step 3 applies in pass order and needs those labels. Detect overlaps where two passes touch the same region (common: a state pass and an effect pass on the same block, or a memo and callback pass on the same component). Reconcile only genuine same-region conflicts, and drop proposals a sibling pass has made moot; a reconciled change inherits the pass label of whichever of its passes comes first in the Step 3 dependency order (effects → state → memo → callback → React Query → url-state → emcn → comments), so it is applied at the earliest safe point. Non-overlapping proposals stay as-is with their own labels. The output is a per-pass list of surviving changes, not a per-file patch. +Collect all findings into one list, **keeping each proposal tagged with the pass that produced it** — do NOT collapse a file's proposals into a single unlabeled patch, because Step 3 applies in pass order and needs those labels. Detect overlaps where two passes touch the same region (common: a state pass and an effect pass on the same block, or a memo and callback pass on the same component). Reconcile only genuine same-region conflicts, and drop proposals a sibling pass has made moot; a reconciled change inherits the pass label of whichever of its passes comes first in the Step 3 dependency order (effects → state → memo → callback → React Query → url-state → emcn → comments → tests), so it is applied at the earliest safe point. Non-overlapping proposals stay as-is with their own labels. The output is a per-pass list of surviving changes, not a per-file patch. ## Step 3 — Sequential apply @@ -39,11 +40,11 @@ If `fix=false`, skip this step — just report the proposals from Step 2. Otherwise apply the surviving changes yourself (in the main context, not delegated), iterating **pass by pass** in this dependency order so earlier structural changes settle before later passes build on them: -1. effects → 2. state → 3. memo → 4. callback → 5. React Query → 6. url-state → 7. emcn design → 8. comments +1. effects → 2. state → 3. memo → 4. callback → 5. React Query → 6. url-state → 7. emcn design → 8. comments → 9. tests For each pass in turn, apply all of that pass's changes, then move to the next pass. A file touched by several passes is therefore edited once per pass, in this order — not once as a merged patch. This is what makes the ordering real: a single merged-per-file patch would collapse all passes into one edit and lose it. -Comments apply last, on purpose: that pass operates on whatever the earlier structural passes settled the code into, so it never edits lines a sibling pass is about to delete or rewrite. +Comments apply after every structural pass, on purpose: that pass operates on whatever the earlier passes settled the code into, so it never edits lines a sibling pass is about to delete or rewrite. Tests apply last because they only touch test files; in Step 2, drop any other pass's proposal on a test file the tests pass deletes. **Treat every Step 1 proposal as snapshot-relative, not authoritative.** All passes analyzed the *original* files in parallel, so a proposal's line ranges and before/after text describe the code as it was *before* any edits — once an earlier pass has run, a later pass's snippet may no longer match. So for each change, before applying: @@ -55,7 +56,7 @@ After all edits, run `bun run lint:check` (it runs `turbo run lint:check` across ## Step 4 — Summary -Output a summary across all eight passes: what each found, what was applied vs. skipped-as-redundant, and any proposals that need a human decision. +Output a summary across all passes that ran: what each found, what was applied vs. skipped-as-redundant, and any proposals that need a human decision. ## Boundary findings diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 943ea393f87..b866ee9a149 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -30,8 +30,9 @@ When the user runs `/ship`: - Types: `fix`, `feat`, `improvement`, `chore` - Scope: short identifier (e.g., `undo-redo`, `api`, `ui`) - Keep it concise -4. **Run the cleanup pass** — only if the diff modifies UI code (any `.tsx` file, or anything under `apps/sim/components/`, `apps/sim/hooks/`, or `apps/sim/stores/`): `/cleanup` - - `/cleanup` fans out the React/UI passes (effects, memo, callbacks, state, React Query, emcn, url-state) plus the comment pass; skip it when no UI was touched. When it runs, it applies fixes so they land in this commit. +4. **Run the cleanup and test gates** + - If the diff modifies UI code (any non-test `.tsx` file, or anything under `apps/sim/components/`, `apps/sim/hooks/`, or `apps/sim/stores/`), run `/cleanup`. It fans out the React/UI passes (effects, memo, callbacks, state, React Query, emcn, url-state), the comment pass, and the test-audit pass, and applies fixes so they land in this commit. + - Otherwise, if the diff adds or changes tests (`*.test.ts(x)`, `*.integration.ts`, `e2e/**`), run `/test-audit audit ` on its own. Every new or changed test must pass the authoring gate; delete the ones that don't rather than shipping them. 5. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`: - Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version). - `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy. @@ -143,7 +144,7 @@ Tested manually (or describe testing) ## Checklist - [x] Code follows project style guidelines - [x] Self-reviewed my changes -- [ ] Tests added/updated and passing +- [ ] Tests added/updated and passing (new tests pass the `test-audit` authoring gate) - [x] No new warnings introduced - [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla) ``` diff --git a/.agents/skills/test-audit/SKILL.md b/.agents/skills/test-audit/SKILL.md new file mode 100644 index 00000000000..bd685aa6f20 --- /dev/null +++ b/.agents/skills/test-audit/SKILL.md @@ -0,0 +1,148 @@ +--- +name: test-audit +description: Invoke whenever writing, changing, reviewing, or sweeping tests. Authoring gate for new tests, plus an audit workflow for low-value, implementation-coupled, or duplicative tests and the test-only production seams they demand. +argument-hint: "[author | audit | campaign ]" +--- + +# Test Audit + +Three modes, one value bar. **Authoring** gates every new or changed test at write time. +**Audit** runs a focused sweep of existing tests that re-assert source, duplicate stronger proof, +couple to implementation, or keep test-only production seams alive. **Campaign** prunes one +subsystem's whole test surface in parallel lanes. Optimize for confidence, not deletion count — +but a test that cannot name the bug it catches is cost, not coverage. + +Read `.claude/rules/sim-testing.md` first: it defines the test layers, file naming, and the +mechanics (global mocks, `@sim/testing`, performance rules). + +## The three rules + +1. **Never write unit tests after you write code.** A test written to describe code that already + exists restates the implementation, passes on the first run, and proves nothing. If the change + needs proof, prove it end to end. +2. **Prefer E2E tests; use them to verify complex features.** Exercise the real boundary — real + Postgres/Redis (`*.integration.ts`), a running app over real HTTP, or the packaged desktop app + (Playwright). Every E2E run ends with a **verifiable, repeatable artifact**: a JSON report of + checks with pass/fail and durations, an HTTP status log, a trace, or a screenshot, written to a + path the caller controls (`_REPORT_PATH`) and uploaded by CI on failure. + `apps/sim/scripts/test-scim-e2e.ts` is the reference shape. +3. **If you must test a system in isolation, write the failure modes down first, then write the + code.** List every way the unit can fail (bad input, boundary, concurrency, partial failure, + permission denial, resource cap). Each listed mode becomes one test that fails before the code + exists. A mode you cannot list is not a test you should write. + +## Authoring gate + +Before adding any test, answer all four. A missing answer means do not add it. + +1. What observable behavior, invariant, or independent contract does it protect? +2. What credible regression makes it fail? +3. Why does existing coverage not already catch that failure? Type-check, `next build`, + `bun run check:audits`, and the integration/E2E suites are coverage too. Each contract has one + primary owner at the strongest boundary; another layer needs its own distinct risk. Prefer + extending an existing table-driven case over a near-duplicate test. +4. Does it need a production seam (export, flag, wrapper, injection hook) that no production + caller needs? If yes, test at the real boundary instead. + +Then check it against every junk pattern below. A match fails the gate unless the retention bar +names the contract it independently guards. A test that would break under behavior-preserving +refactoring asserts implementation, not behavior. + +**Regression tests** must fail on the pre-fix code for the intended reason. Revert each guard of +the fix separately and watch the test named for that guard go red, then restore. A regression +test that never demonstrably failed proves the mock, not the fix. One regression at the owner +boundary covers the bug; do not replay it at every layer it crosses. + +## Junk patterns + +- assertion-free or `toBeDefined()`-only tests; "renders without crashing"; +- restating declarations: block/tool/trigger/provider config (subBlock ids, params, outputs, URL + templates, header maps), constant tables, registries, enums, export lists — type-check and + `check:audits` own these; +- route/handler tests that mock every collaborator and assert `toHaveBeenCalledWith` on the mocks, + or re-assert a mock's canned return; +- mocks that implement the asserted behavior, or one mock standing in for different APIs; +- Zod contract tests proving a schema accepts a valid object or rejects an obviously invalid one; +- React tests of text, class names, aria presence, snapshots, "calls onClick"; +- hook tests asserting query keys or fetch URLs; store tests of trivial setters; +- tests of test infrastructure (mocks, factories, builders testing themselves); +- source-text or import greps (`readFileSync(src)` + `toContain`); +- expected values produced by the helper under test; +- duplicate invocations of the same contract, or provider-local replays of a shared helper; +- fixtures that supply the ordering, receipt, or callback the owner should produce; +- negative controls that pass for an unrelated reason (a different guard short-circuits first); +- names or fixtures that promise more than the input exercises; +- dead production code or exports whose only callers are tests. + +## Retention bar + +Keep a test when it independently enforces one of: + +- **security** — authn/authz denial, tenant/workspace isolation, SSRF/URL validation, secret + redaction, encryption, signature verification, path traversal, injection, rate limits; +- **money and data integrity** — billing/usage math, metering, quotas, idempotency, migrations, + persistence semantics, concurrency/locking/leases, outbox, retries; +- **executor semantics** — DAG traversal, loops/parallels, conditions/routers, reference + resolution, streaming, pause/resume, run-from-block, cancellation; +- **real algorithms with edge cases** — chunkers, parsers, query builders, cron, diff/merge, + pagination, encoding, date math, ranking; +- **cross-process wire contracts** — realtime protocol, desktop bridge/IPC, CLI/SDK wire, provider + webhooks, MCP — that type-check cannot see; +- **a regression with a credible repeat**, shown red on the pre-fix code. + +Also keep call ordering when order is observable, and a source inspection when it is the cheapest +independent guard of a user-facing byte, key, or path. A retained test that fails on the baseline +is a possible product bug: reproduce it and fix the owner rather than deleting it. Static or slow +is not a deletion reason. + +## Audit mode + +Keep discovery read-only and report evidence before editing. Before judging a candidate, read the +complete test and its production owner, callers, sibling implementations, overlapping tests, CI +routing (CI discovers `*.integration.ts` by glob; `.github/workflows/*.yml` names a few scripts and +files by path), and relevant history +(`git log --format='%h %s' -5 -- `). + +Record for every deletion candidate: the test and location; the failure it can actually detect; +non-test callers of the seam it covers; the stronger remaining proof (or why none is needed); the +production or test-support code its deletion unlocks; and the focused validation command. + +**Edit shape.** One coherent owner-boundary batch per PR. When pruning inside a file, also delete +now-unused imports, mocks, fixtures, and helpers. Delete test-only exports and dead production +paths instead of preserving aliases (`rg -n '' --glob '!**/*.test.*'` must show no other +reference, including string and dynamic-import references; never delete route files, registry +entries, or generated files). Prefer net-negative production LOC. Do not add replacement tests +that restate the same implementation. + +## Campaign mode + +For a whole subsystem or the whole repo: + +1. Partition test files into lanes of ~150–350 files by owning directory, and list the protected + set (every `*.integration.ts`, `*.live.test.ts`, `__integration__/**`, `apps/desktop/e2e/**`, + and every path named in `.github/workflows/*.yml`). +2. Give each lane its own git worktree and branch (`git worktree add -b `, + then `bun install --frozen-lockfile` inside it). Lanes never share a checkout, never symlink + `node_modules`, and never use `git stash` — the stash is shared across worktrees. +3. Each lane commits once and writes a report: counts, categories removed with examples, notable + keeps and why, production seams removed with grep evidence, and the commands it ran. +4. Merge lane branches, then sweep orphaned shared test support (`packages/testing/**`, fixtures, + helpers) that no remaining test imports. + +## Validation + +Never edit source or tests while Vitest is running in the same checkout. + +1. Run the touched and sibling test files. From `apps/sim`: + `../../node_modules/.bin/vitest run ` (never `bunx vitest`, which fetches a different + Vitest). Other workspaces: run from the workspace directory. Never pipe the runner through + `grep`/`tail` where the pipe hides its exit code. +2. If production code changed: `bun run type-check` in that workspace. +3. `bun run check:audits` from the repo root (some audits list test files by path). +4. `bun run lint`, then `git diff --check`. +5. Report `git diff --shortstat` with production and test changes counted separately. + +## Handoff + +Report the categories removed, production simplifications, retained false positives and why they +stay, the validation actually run, production vs test LOC, and named follow-ups. diff --git a/.claude/rules/sim-testing.md b/.claude/rules/sim-testing.md index 13c9044e594..3434dc273f9 100644 --- a/.claude/rules/sim-testing.md +++ b/.claude/rules/sim-testing.md @@ -1,258 +1,127 @@ --- -description: Testing patterns with Vitest and @sim/testing +description: Test layers, naming, and Vitest mechanics for Sim paths: - - "apps/sim/**/*.test.ts" - - "apps/sim/**/*.test.tsx" + - "**/*.test.ts" + - "**/*.test.tsx" + - "**/*.integration.ts" + - "**/e2e/**" --- -# Testing Patterns +# Testing -Use Vitest. Test files: `feature.ts` → `feature.test.ts` +Before writing or changing any test, run the `test-audit` skill's authoring gate. The short +version: never write unit tests after the code; prefer E2E tests at the real boundary and end +them with a verifiable artifact; if you must test in isolation, write the failure modes down +first. A test that cannot name the bug it catches does not get written. -## Global Mocks (vitest.setup.ts) +## Layers -These modules are mocked globally — do NOT re-mock them in test files unless you need to override behavior: +What already catches bugs, in the order to reach for it: -- `@sim/db` → `databaseMock` -- `@sim/db/schema` → `schemaMock` -- `drizzle-orm` → `drizzleOrmMock` -- `@sim/logger` → `loggerMock` -- `@/lib/auth` → `authMock` -- `@/lib/auth/hybrid` → `hybridAuthMock` (with default session-delegating behavior) -- `@/lib/core/utils/request` → `requestUtilsMock` -- `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` -- `@/blocks/registry` -- `@trigger.dev/sdk` -- `@sim/platform-authz/workflow` → `workflowAuthzMock` - -## Structure - -```typescript -/** - * @vitest-environment node - */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetSession } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - -import { GET, POST } from '@/app/api/my-route/route' - -describe('my route', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - }) - - it('returns data', async () => { - const req = createMockRequest('GET') - const res = await GET(req) - expect(res.status).toBe(200) - }) -}) -``` - -## Performance Rules (Critical) - -### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()` - -This is the #1 cause of slow tests. It forces complete module re-evaluation per test. - -```typescript -// BAD — forces module re-evaluation every test (~50-100ms each) -beforeEach(() => { - vi.resetModules() - vi.doMock('@/lib/auth', () => ({ getSession: vi.fn() })) -}) -it('test', async () => { - const { GET } = await import('./route') // slow dynamic import -}) - -// GOOD — module loaded once, mocks reconfigured per test (~1ms each) -const { mockGetSession } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), -})) -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -import { GET } from '@/app/api/my-route/route' - -beforeEach(() => { vi.clearAllMocks() }) -it('test', () => { - mockGetSession.mockResolvedValue({ user: { id: '1' } }) -}) -``` - -**Only exception:** Singleton modules that cache state at module scope (e.g., Redis clients, connection pools). These genuinely need `vi.resetModules()` + dynamic import to get a fresh instance per test. - -### NEVER use `vi.importActual()` - -This defeats the purpose of mocking by loading the real module and all its dependencies. - -```typescript -// BAD — loads real module + all transitive deps -vi.mock('@/lib/workspaces/utils', async () => { - const actual = await vi.importActual('@/lib/workspaces/utils') - return { ...actual, myFn: vi.fn() } -}) - -// GOOD — mock everything, only implement what tests need -vi.mock('@/lib/workspaces/utils', () => ({ - myFn: vi.fn(), - otherFn: vi.fn(), -})) -``` - -### Mock heavy transitive dependencies - -If a module under test imports `@/blocks` (200+ files), `@/tools/registry`, or other heavy modules, mock them: - -```typescript -vi.mock('@/blocks', () => ({ - getBlock: () => null, - getAllBlocks: () => ({}), - getAllBlockTypes: () => [], - registry: {}, -})) -``` - -### Use `@vitest-environment node` unless DOM is needed - -Only use `@vitest-environment jsdom` if the test uses `window`, `document`, `FormData`, or other browser APIs. Node environment is significantly faster. - -### Avoid real timers in tests - -```typescript -// BAD -await new Promise(r => setTimeout(r, 500)) - -// GOOD — use minimal delays or fake timers -await new Promise(r => setTimeout(r, 1)) -// or -vi.useFakeTimers() -``` - -## Centralized Mocks (prefer over local declarations) - -`@sim/testing` exports ready-to-use mock modules for common dependencies. Import and pass directly to `vi.mock()` — no `vi.hoisted()` boilerplate needed. Each paired `*MockFns` object exposes the underlying `vi.fn()`s for per-test overrides. - -| Module mocked | Import | Factory form | +| Layer | What it proves | Where | |---|---|---| -| `@/app/api/auth/oauth/utils` | `authOAuthUtilsMock`, `authOAuthUtilsMockFns` | `vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)` | -| `@/app/api/knowledge/utils` | `knowledgeApiUtilsMock`, `knowledgeApiUtilsMockFns` | `vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)` | -| `@/app/api/workflows/utils` | `workflowsApiUtilsMock`, `workflowsApiUtilsMockFns` | `vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)` | -| `@sim/audit` | `auditMock`, `auditMockFns` | `vi.mock('@sim/audit', () => auditMock)` | -| `@/lib/auth` | `authMock`, `authMockFns` | `vi.mock('@/lib/auth', () => authMock)` | -| `@/lib/auth/hybrid` | `hybridAuthMock`, `hybridAuthMockFns` | `vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)` | -| `@/lib/copilot/request/http` | `copilotHttpMock`, `copilotHttpMockFns` | `vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)` | -| `@/lib/core/config/env` | `envMock`, `createEnvMock(overrides)` | `vi.mock('@/lib/core/config/env', () => envMock)` | -| `@/lib/core/config/env-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/env-flags', () => featureFlagsMock)` | -| `@/lib/core/config/redis` | `redisConfigMock`, `redisConfigMockFns` | `vi.mock('@/lib/core/config/redis', () => redisConfigMock)` | -| `@/lib/core/security/encryption` | `encryptionMock`, `encryptionMockFns` | `vi.mock('@/lib/core/security/encryption', () => encryptionMock)` | -| `@/lib/core/security/input-validation.server` | `inputValidationMock`, `inputValidationMockFns` | `vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)` | -| `@/lib/core/utils/request` | `requestUtilsMock`, `requestUtilsMockFns` | `vi.mock('@/lib/core/utils/request', () => requestUtilsMock)` | -| `@/lib/core/utils/urls` | `urlsMock`, `urlsMockFns` | `vi.mock('@/lib/core/utils/urls', () => urlsMock)` | -| `@/lib/execution/preprocessing` | `executionPreprocessingMock`, `executionPreprocessingMockFns` | `vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)` | -| `@/lib/logs/execution/logging-session` | `loggingSessionMock`, `loggingSessionMockFns`, `LoggingSessionMock` | `vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)` | -| `@/lib/workflows/orchestration` | `workflowsOrchestrationMock`, `workflowsOrchestrationMockFns` | `vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)` | -| `@/lib/workflows/persistence/utils` | `workflowsPersistenceUtilsMock`, `workflowsPersistenceUtilsMockFns` | `vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)` | -| `@/lib/workflows/utils` | `workflowsUtilsMock`, `workflowsUtilsMockFns` | `vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)` | -| `@/lib/workspaces/permissions/utils` | `permissionsMock`, `permissionsMockFns` | `vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)` | -| `@sim/db/schema` | `schemaMock` | `vi.mock('@sim/db/schema', () => schemaMock)` | - -### Auth mocking (API routes) +| Type-check, `next build` | shapes, imports, wiring | `bunx turbo run type-check`, CI build | +| Repo audits | registry consistency, API contract boundaries, tool/block/icon invariants, migrations | `bun run check:audits` | +| E2E / integration | real Postgres/Redis, real HTTP, packaged desktop app | `*.integration.ts`, `apps/sim/scripts/test-*-e2e.ts`, `apps/desktop/e2e/*.spec.ts` | +| Unit | one isolated unit's listed failure modes | `*.test.ts(x)` | + +A unit test is justified only for a failure the layers above cannot see: security boundaries, +billing math, executor semantics, parsers and algorithms with edge cases, cross-process wire +contracts, and demonstrated regressions. Never restate declarations (block/tool/provider config, +registries, constants, schemas accepting valid input), assert that mocks were called, check +rendered text or class names, or test mocks and factories themselves. + +## Naming + +| Suffix | Needs | Run with | In CI | +|--------|-------|----------|-------| +| `*.test.ts(x)` | nothing; global mocks from `vitest.setup.ts` | `vitest run` | Lint and Test job | +| `*.integration.ts` | real PostgreSQL (`TEST_DATABASE_URL`), optionally Redis (`TEST_REDIS_URL`) | `vitest run --mode integration` | `PostgreSQL integration` job, by glob | +| `*.live.test.ts` | provider APIs, hosted sandboxes, local runtimes, or sibling checkouts | `vitest run --mode live ` (apps/sim) | never | +| `apps/desktop/e2e/*.spec.ts` | the packaged Electron app | Playwright | desktop E2E workflow | +| `apps/sim/scripts/test-*-e2e.ts` | a running app over HTTP | `bun run test::e2e` | PostgreSQL integration job | + +- A unit test lives next to its source: `feature.ts` → `feature.test.ts`. No network, no database, + no real timers. +- Integration mode installs **no** global mocks: `vitest.integration.setup.ts` validates the env + contract, scrubs application env, and sets fixture env. Declare every fixture the suite needs with + `vi.mock` in the file. +- `TEST_DATABASE_URL` must be loopback and its database name must contain a `test` segment + (`sim_test`); `TEST_REDIS_URL` must be loopback. `packages/db/testing/test-infrastructure.ts` owns + those checks. Isolate with a unique schema or generated IDs, and clean up in `afterAll`. +- Integration files run one at a time against one shared database. A new `*.integration.ts` is + picked up by CI with no workflow change, and the run writes `test-results/integration.json`, which + CI uploads. Never add a passing suite to the quarantine list in `apps/sim/vitest.config.ts`. +- `bun run test:integration` starts disposable Postgres and Redis containers, provisions the schema, + and runs both workspaces; pass filenames to narrow the `apps/sim` run. + +Name the `describe`/`it` for the behavior and the condition (`it('rejects a token issued for +another workspace')`), never the implementation (`it('calls verifyToken')`). + +## E2E artifacts + +Every E2E or integration run ends with an artifact a reviewer can inspect and re-run: a JSON +report of each check (name, status, duration, error), an HTTP status log, a Playwright trace, or a +screenshot. Write it to a caller-supplied path (`_REPORT_PATH`) and have CI upload it. +`apps/sim/scripts/test-scim-e2e.ts` is the reference: it asserts its environment is loopback and +disposable, seeds with SQL, exercises the real HTTP boundary, cleans up its fixtures, and writes +the report. + +## Unit test mechanics + +### Global mocks (`apps/sim/vitest.setup.ts`) + +Mocked for every unit test file — do not re-mock unless you override behavior: `@sim/db`, +`@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/platform-authz/workflow`, `@/lib/auth`, +`@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@/lib/core/config/env`, +`@/lib/core/config/env-flags`, `@/lib/core/utils/urls`, `@/lib/core/config/redis`, +`@/lib/environment/utils`, the console/terminal/execution stores, `@/blocks/registry`, and +`@trigger.dev/sdk`. + +### Structure ```typescript -import { authMock, authMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/auth', () => authMock) - +import { authMockFns, createMockRequest } from '@sim/testing' +import { describe, expect, it } from 'vitest' import { GET } from '@/app/api/my-route/route' -beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) -}) -``` - -Only define a local `vi.mock('@/lib/auth', ...)` if the module under test consumes exports outside the centralized shape (e.g., `auth.api.verifyOneTimeToken`, `auth.api.resetPassword`). - -### Hybrid auth mocking - -```typescript -import { hybridAuthMock, hybridAuthMockFns } from '@sim/testing' - -vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) - -// In tests: -hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, userId: 'user-1', authType: 'session', -}) -``` - -### Database chain mocking - -Use the centralized `dbChainMock` + `dbChainMockFns` helpers — no `vi.hoisted()` or chain-wiring boilerplate needed. - -```typescript -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' - -vi.mock('@sim/db', () => dbChainMock) -// Spread for custom exports: vi.mock('@sim/db', () => ({ ...dbChainMock, myTable: {...} })) - -beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() // only needed if tests use permanent (non-`Once`) overrides -}) - -it('reads a row', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: '1', name: 'test' }]) - // exercise code that hits db.select().from().where().limit() - expect(dbChainMockFns.where).toHaveBeenCalled() +describe('GET /api/my-route', () => { + it('returns 401 without a session', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + const res = await GET(createMockRequest('GET')) + expect(res.status).toBe(401) + }) }) ``` -**Default chains supported:** -- `select()/selectDistinct()/selectDistinctOn() → from() → where()/innerJoin()/leftJoin() → where() → limit()/orderBy()/returning()/groupBy()/for()` -- `insert() → values() → returning()/onConflictDoUpdate()/onConflictDoNothing()` -- `update() → set() → where() → limit()/orderBy()/returning()/for()` -- `delete() → where() → limit()/orderBy()/returning()/for()` -- `db.execute()` resolves `[]` -- `db.transaction(cb)` calls cb with `dbChainMock.db` - -`.for('update')` (Postgres row-level locking) is supported on `where` builders. It returns a thenable with `.limit` / `.orderBy` / `.returning` / `.groupBy` attached, so both `await .where().for('update')` (terminal) and `await .where().for('update').limit(1)` (chained) work. Override the terminal result with `dbChainMockFns.for.mockResolvedValueOnce([...])`; for the chained form, mock the downstream terminal (e.g. `dbChainMockFns.limit.mockResolvedValueOnce([...])`). - -All terminals default to `Promise.resolve([])`. Override per-test with `dbChainMockFns..mockResolvedValueOnce(...)`. Use `resetDbChainMock()` in `beforeEach` only when tests replace wiring with `.mockReturnValue` / `.mockResolvedValue` (permanent); tests using only `...Once` variants don't need it. - -## @sim/testing Package - -Always prefer over local test data. - -| Category | Utilities | -|----------|-----------| -| **Module mocks** | See "Centralized Mocks" table above | -| **Logger helpers** | `loggerMock`, `createMockLogger()`, `getLoggerCalls()`, `clearLoggerMocks()` | -| **Database helpers** | `databaseMock`, `drizzleOrmMock`, `createMockDb()`, `createMockSql()`, `createMockSqlOperators()` | -| **Fetch helpers** | `setupGlobalFetchMock()`, `createMockFetch()`, `createMockResponse()`, `mockFetchError()` | -| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` | -| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | -| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | -| **Requests** | `createMockRequest()`, `createMockFormDataRequest()` | - -## Rules Summary - -1. `@vitest-environment node` unless DOM is required -2. Prefer centralized mocks from `@sim/testing` (see table above) over local `vi.hoisted()` + `vi.mock()` boilerplate -3. `vi.hoisted()` + `vi.mock()` + static imports — never `vi.resetModules()` + `vi.doMock()` + dynamic imports -4. `vi.mock()` calls before importing mocked modules -5. `beforeEach(() => vi.clearAllMocks())` to reset state — no redundant `afterEach` -6. No `vi.importActual()` — mock everything explicitly -7. Mock heavy deps (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them -8. Use absolute imports in test files -9. Avoid real timers — use 1ms delays or `vi.useFakeTimers()` +Prefer the centralized mocks in `@sim/testing` (`authMock`/`authMockFns`, `hybridAuthMock`, +`dbChainMock`/`dbChainMockFns`, `envMock`, `redisConfigMock`, `encryptionMock`, …) over local +`vi.hoisted()` + `vi.mock()` boilerplate; import the mock file directly +(`@sim/testing/mocks/.mock`) when you only need one. Mocks with per-test knobs export a paired +`*MockFns` object for overrides; the rest expose their `vi.fn()`s directly on the mock. + +### Performance rules + +The suite's wall time is bound by the single Vite server thread that serves every module fetch +and `vi.mock` resolve, so the lever is fewer modules and fewer mocks per file, not more workers. + +1. `vi.hoisted()` + `vi.mock()` + static imports. Never `vi.resetModules()` + `vi.doMock()` + + dynamic `import()` — it re-evaluates the module graph per test. The only exception is a module + that caches a singleton at module scope. +2. Never `vi.importActual()` — it loads the real module and its whole graph. Mock explicitly. +3. Mock heavy graphs you do not need: `@/blocks`, `@/tools/registry`, `@/triggers/registry`, + `@/tools/generated/*`. +4. Node environment is the default — never write `@vitest-environment node`. Add + `/** @vitest-environment jsdom */` only when the test needs + `window`, `document`, or other DOM APIs. +5. No real timers: `vi.useFakeTimers()` or a 1ms delay. +6. Vitest clears mock call history before every test (`clearMocks` is on by default), so never + add `vi.clearAllMocks()` to a `beforeEach`. Reset implementations explicitly only where a test + installs a permanent one (`mockReturnValue`, not `mockReturnValueOnce`). +7. Absolute imports only. + +### Running + +From `apps/sim`: `../../node_modules/.bin/vitest run `. Other workspaces: run from the +workspace directory. `bunx vitest` fetches a different Vitest and fails to load the config. +Never pipe the runner through `grep`/`tail` in a script where the pipe hides its exit code. diff --git a/.claude/skills/test-audit b/.claude/skills/test-audit new file mode 120000 index 00000000000..7fb9bcd8b32 --- /dev/null +++ b/.claude/skills/test-audit @@ -0,0 +1 @@ +../../.agents/skills/test-audit \ No newline at end of file diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index 2ce946647a7..b9ede0d1b85 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -1,258 +1,125 @@ --- -description: "Testing patterns with Vitest and @sim/testing" -globs: ["apps/sim/**/*.test.ts","apps/sim/**/*.test.tsx"] +description: "Test layers, naming, and Vitest mechanics for Sim" +globs: ["**/*.test.ts","**/*.test.tsx","**/*.integration.ts","**/e2e/**"] --- -# Testing Patterns +# Testing -Use Vitest. Test files: `feature.ts` → `feature.test.ts` +Before writing or changing any test, run the `test-audit` skill's authoring gate. The short +version: never write unit tests after the code; prefer E2E tests at the real boundary and end +them with a verifiable artifact; if you must test in isolation, write the failure modes down +first. A test that cannot name the bug it catches does not get written. -## Global Mocks (vitest.setup.ts) +## Layers -These modules are mocked globally — do NOT re-mock them in test files unless you need to override behavior: +What already catches bugs, in the order to reach for it: -- `@sim/db` → `databaseMock` -- `@sim/db/schema` → `schemaMock` -- `drizzle-orm` → `drizzleOrmMock` -- `@sim/logger` → `loggerMock` -- `@/lib/auth` → `authMock` -- `@/lib/auth/hybrid` → `hybridAuthMock` (with default session-delegating behavior) -- `@/lib/core/utils/request` → `requestUtilsMock` -- `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` -- `@/blocks/registry` -- `@trigger.dev/sdk` -- `@sim/platform-authz/workflow` → `workflowAuthzMock` - -## Structure - -```typescript -/** - * @vitest-environment node - */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetSession } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - -import { GET, POST } from '@/app/api/my-route/route' - -describe('my route', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - }) - - it('returns data', async () => { - const req = createMockRequest('GET') - const res = await GET(req) - expect(res.status).toBe(200) - }) -}) -``` - -## Performance Rules (Critical) - -### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()` - -This is the #1 cause of slow tests. It forces complete module re-evaluation per test. - -```typescript -// BAD — forces module re-evaluation every test (~50-100ms each) -beforeEach(() => { - vi.resetModules() - vi.doMock('@/lib/auth', () => ({ getSession: vi.fn() })) -}) -it('test', async () => { - const { GET } = await import('./route') // slow dynamic import -}) - -// GOOD — module loaded once, mocks reconfigured per test (~1ms each) -const { mockGetSession } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), -})) -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -import { GET } from '@/app/api/my-route/route' - -beforeEach(() => { vi.clearAllMocks() }) -it('test', () => { - mockGetSession.mockResolvedValue({ user: { id: '1' } }) -}) -``` - -**Only exception:** Singleton modules that cache state at module scope (e.g., Redis clients, connection pools). These genuinely need `vi.resetModules()` + dynamic import to get a fresh instance per test. - -### NEVER use `vi.importActual()` - -This defeats the purpose of mocking by loading the real module and all its dependencies. - -```typescript -// BAD — loads real module + all transitive deps -vi.mock('@/lib/workspaces/utils', async () => { - const actual = await vi.importActual('@/lib/workspaces/utils') - return { ...actual, myFn: vi.fn() } -}) - -// GOOD — mock everything, only implement what tests need -vi.mock('@/lib/workspaces/utils', () => ({ - myFn: vi.fn(), - otherFn: vi.fn(), -})) -``` - -### Mock heavy transitive dependencies - -If a module under test imports `@/blocks` (200+ files), `@/tools/registry`, or other heavy modules, mock them: - -```typescript -vi.mock('@/blocks', () => ({ - getBlock: () => null, - getAllBlocks: () => ({}), - getAllBlockTypes: () => [], - registry: {}, -})) -``` - -### Use `@vitest-environment node` unless DOM is needed - -Only use `@vitest-environment jsdom` if the test uses `window`, `document`, `FormData`, or other browser APIs. Node environment is significantly faster. - -### Avoid real timers in tests - -```typescript -// BAD -await new Promise(r => setTimeout(r, 500)) - -// GOOD — use minimal delays or fake timers -await new Promise(r => setTimeout(r, 1)) -// or -vi.useFakeTimers() -``` - -## Centralized Mocks (prefer over local declarations) - -`@sim/testing` exports ready-to-use mock modules for common dependencies. Import and pass directly to `vi.mock()` — no `vi.hoisted()` boilerplate needed. Each paired `*MockFns` object exposes the underlying `vi.fn()`s for per-test overrides. - -| Module mocked | Import | Factory form | +| Layer | What it proves | Where | |---|---|---| -| `@/app/api/auth/oauth/utils` | `authOAuthUtilsMock`, `authOAuthUtilsMockFns` | `vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)` | -| `@/app/api/knowledge/utils` | `knowledgeApiUtilsMock`, `knowledgeApiUtilsMockFns` | `vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)` | -| `@/app/api/workflows/utils` | `workflowsApiUtilsMock`, `workflowsApiUtilsMockFns` | `vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)` | -| `@sim/audit` | `auditMock`, `auditMockFns` | `vi.mock('@sim/audit', () => auditMock)` | -| `@/lib/auth` | `authMock`, `authMockFns` | `vi.mock('@/lib/auth', () => authMock)` | -| `@/lib/auth/hybrid` | `hybridAuthMock`, `hybridAuthMockFns` | `vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)` | -| `@/lib/copilot/request/http` | `copilotHttpMock`, `copilotHttpMockFns` | `vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)` | -| `@/lib/core/config/env` | `envMock`, `createEnvMock(overrides)` | `vi.mock('@/lib/core/config/env', () => envMock)` | -| `@/lib/core/config/env-flags` | `featureFlagsMock` | `vi.mock('@/lib/core/config/env-flags', () => featureFlagsMock)` | -| `@/lib/core/config/redis` | `redisConfigMock`, `redisConfigMockFns` | `vi.mock('@/lib/core/config/redis', () => redisConfigMock)` | -| `@/lib/core/security/encryption` | `encryptionMock`, `encryptionMockFns` | `vi.mock('@/lib/core/security/encryption', () => encryptionMock)` | -| `@/lib/core/security/input-validation.server` | `inputValidationMock`, `inputValidationMockFns` | `vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)` | -| `@/lib/core/utils/request` | `requestUtilsMock`, `requestUtilsMockFns` | `vi.mock('@/lib/core/utils/request', () => requestUtilsMock)` | -| `@/lib/core/utils/urls` | `urlsMock`, `urlsMockFns` | `vi.mock('@/lib/core/utils/urls', () => urlsMock)` | -| `@/lib/execution/preprocessing` | `executionPreprocessingMock`, `executionPreprocessingMockFns` | `vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)` | -| `@/lib/logs/execution/logging-session` | `loggingSessionMock`, `loggingSessionMockFns`, `LoggingSessionMock` | `vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)` | -| `@/lib/workflows/orchestration` | `workflowsOrchestrationMock`, `workflowsOrchestrationMockFns` | `vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)` | -| `@/lib/workflows/persistence/utils` | `workflowsPersistenceUtilsMock`, `workflowsPersistenceUtilsMockFns` | `vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)` | -| `@/lib/workflows/utils` | `workflowsUtilsMock`, `workflowsUtilsMockFns` | `vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)` | -| `@/lib/workspaces/permissions/utils` | `permissionsMock`, `permissionsMockFns` | `vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)` | -| `@sim/db/schema` | `schemaMock` | `vi.mock('@sim/db/schema', () => schemaMock)` | - -### Auth mocking (API routes) +| Type-check, `next build` | shapes, imports, wiring | `bunx turbo run type-check`, CI build | +| Repo audits | registry consistency, API contract boundaries, tool/block/icon invariants, migrations | `bun run check:audits` | +| E2E / integration | real Postgres/Redis, real HTTP, packaged desktop app | `*.integration.ts`, `apps/sim/scripts/test-*-e2e.ts`, `apps/desktop/e2e/*.spec.ts` | +| Unit | one isolated unit's listed failure modes | `*.test.ts(x)` | + +A unit test is justified only for a failure the layers above cannot see: security boundaries, +billing math, executor semantics, parsers and algorithms with edge cases, cross-process wire +contracts, and demonstrated regressions. Never restate declarations (block/tool/provider config, +registries, constants, schemas accepting valid input), assert that mocks were called, check +rendered text or class names, or test mocks and factories themselves. + +## Naming + +| Suffix | Needs | Run with | In CI | +|--------|-------|----------|-------| +| `*.test.ts(x)` | nothing; global mocks from `vitest.setup.ts` | `vitest run` | Lint and Test job | +| `*.integration.ts` | real PostgreSQL (`TEST_DATABASE_URL`), optionally Redis (`TEST_REDIS_URL`) | `vitest run --mode integration` | `PostgreSQL integration` job, by glob | +| `*.live.test.ts` | provider APIs, hosted sandboxes, local runtimes, or sibling checkouts | `vitest run --mode live ` (apps/sim) | never | +| `apps/desktop/e2e/*.spec.ts` | the packaged Electron app | Playwright | desktop E2E workflow | +| `apps/sim/scripts/test-*-e2e.ts` | a running app over HTTP | `bun run test::e2e` | PostgreSQL integration job | + +- A unit test lives next to its source: `feature.ts` → `feature.test.ts`. No network, no database, + no real timers. +- Integration mode installs **no** global mocks: `vitest.integration.setup.ts` validates the env + contract, scrubs application env, and sets fixture env. Declare every fixture the suite needs with + `vi.mock` in the file. +- `TEST_DATABASE_URL` must be loopback and its database name must contain a `test` segment + (`sim_test`); `TEST_REDIS_URL` must be loopback. `packages/db/testing/test-infrastructure.ts` owns + those checks. Isolate with a unique schema or generated IDs, and clean up in `afterAll`. +- Integration files run one at a time against one shared database. A new `*.integration.ts` is + picked up by CI with no workflow change, and the run writes `test-results/integration.json`, which + CI uploads. Never add a passing suite to the quarantine list in `apps/sim/vitest.config.ts`. +- `bun run test:integration` starts disposable Postgres and Redis containers, provisions the schema, + and runs both workspaces; pass filenames to narrow the `apps/sim` run. + +Name the `describe`/`it` for the behavior and the condition (`it('rejects a token issued for +another workspace')`), never the implementation (`it('calls verifyToken')`). + +## E2E artifacts + +Every E2E or integration run ends with an artifact a reviewer can inspect and re-run: a JSON +report of each check (name, status, duration, error), an HTTP status log, a Playwright trace, or a +screenshot. Write it to a caller-supplied path (`_REPORT_PATH`) and have CI upload it. +`apps/sim/scripts/test-scim-e2e.ts` is the reference: it asserts its environment is loopback and +disposable, seeds with SQL, exercises the real HTTP boundary, cleans up its fixtures, and writes +the report. + +## Unit test mechanics + +### Global mocks (`apps/sim/vitest.setup.ts`) + +Mocked for every unit test file — do not re-mock unless you override behavior: `@sim/db`, +`@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/platform-authz/workflow`, `@/lib/auth`, +`@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@/lib/core/config/env`, +`@/lib/core/config/env-flags`, `@/lib/core/utils/urls`, `@/lib/core/config/redis`, +`@/lib/environment/utils`, the console/terminal/execution stores, `@/blocks/registry`, and +`@trigger.dev/sdk`. + +### Structure ```typescript -import { authMock, authMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/auth', () => authMock) - +import { authMockFns, createMockRequest } from '@sim/testing' +import { describe, expect, it } from 'vitest' import { GET } from '@/app/api/my-route/route' -beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) -}) -``` - -Only define a local `vi.mock('@/lib/auth', ...)` if the module under test consumes exports outside the centralized shape (e.g., `auth.api.verifyOneTimeToken`, `auth.api.resetPassword`). - -### Hybrid auth mocking - -```typescript -import { hybridAuthMock, hybridAuthMockFns } from '@sim/testing' - -vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) - -// In tests: -hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, userId: 'user-1', authType: 'session', -}) -``` - -### Database chain mocking - -Use the centralized `dbChainMock` + `dbChainMockFns` helpers — no `vi.hoisted()` or chain-wiring boilerplate needed. - -```typescript -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' - -vi.mock('@sim/db', () => dbChainMock) -// Spread for custom exports: vi.mock('@sim/db', () => ({ ...dbChainMock, myTable: {...} })) - -beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() // only needed if tests use permanent (non-`Once`) overrides -}) - -it('reads a row', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: '1', name: 'test' }]) - // exercise code that hits db.select().from().where().limit() - expect(dbChainMockFns.where).toHaveBeenCalled() +describe('GET /api/my-route', () => { + it('returns 401 without a session', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + const res = await GET(createMockRequest('GET')) + expect(res.status).toBe(401) + }) }) ``` -**Default chains supported:** -- `select()/selectDistinct()/selectDistinctOn() → from() → where()/innerJoin()/leftJoin() → where() → limit()/orderBy()/returning()/groupBy()/for()` -- `insert() → values() → returning()/onConflictDoUpdate()/onConflictDoNothing()` -- `update() → set() → where() → limit()/orderBy()/returning()/for()` -- `delete() → where() → limit()/orderBy()/returning()/for()` -- `db.execute()` resolves `[]` -- `db.transaction(cb)` calls cb with `dbChainMock.db` - -`.for('update')` (Postgres row-level locking) is supported on `where` builders. It returns a thenable with `.limit` / `.orderBy` / `.returning` / `.groupBy` attached, so both `await .where().for('update')` (terminal) and `await .where().for('update').limit(1)` (chained) work. Override the terminal result with `dbChainMockFns.for.mockResolvedValueOnce([...])`; for the chained form, mock the downstream terminal (e.g. `dbChainMockFns.limit.mockResolvedValueOnce([...])`). - -All terminals default to `Promise.resolve([])`. Override per-test with `dbChainMockFns..mockResolvedValueOnce(...)`. Use `resetDbChainMock()` in `beforeEach` only when tests replace wiring with `.mockReturnValue` / `.mockResolvedValue` (permanent); tests using only `...Once` variants don't need it. - -## @sim/testing Package - -Always prefer over local test data. - -| Category | Utilities | -|----------|-----------| -| **Module mocks** | See "Centralized Mocks" table above | -| **Logger helpers** | `loggerMock`, `createMockLogger()`, `getLoggerCalls()`, `clearLoggerMocks()` | -| **Database helpers** | `databaseMock`, `drizzleOrmMock`, `createMockDb()`, `createMockSql()`, `createMockSqlOperators()` | -| **Fetch helpers** | `setupGlobalFetchMock()`, `createMockFetch()`, `createMockResponse()`, `mockFetchError()` | -| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` | -| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | -| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | -| **Requests** | `createMockRequest()`, `createMockFormDataRequest()` | - -## Rules Summary - -1. `@vitest-environment node` unless DOM is required -2. Prefer centralized mocks from `@sim/testing` (see table above) over local `vi.hoisted()` + `vi.mock()` boilerplate -3. `vi.hoisted()` + `vi.mock()` + static imports — never `vi.resetModules()` + `vi.doMock()` + dynamic imports -4. `vi.mock()` calls before importing mocked modules -5. `beforeEach(() => vi.clearAllMocks())` to reset state — no redundant `afterEach` -6. No `vi.importActual()` — mock everything explicitly -7. Mock heavy deps (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them -8. Use absolute imports in test files -9. Avoid real timers — use 1ms delays or `vi.useFakeTimers()` +Prefer the centralized mocks in `@sim/testing` (`authMock`/`authMockFns`, `hybridAuthMock`, +`dbChainMock`/`dbChainMockFns`, `envMock`, `redisConfigMock`, `encryptionMock`, …) over local +`vi.hoisted()` + `vi.mock()` boilerplate; import the mock file directly +(`@sim/testing/mocks/.mock`) when you only need one. Mocks with per-test knobs export a paired +`*MockFns` object for overrides; the rest expose their `vi.fn()`s directly on the mock. + +### Performance rules + +The suite's wall time is bound by the single Vite server thread that serves every module fetch +and `vi.mock` resolve, so the lever is fewer modules and fewer mocks per file, not more workers. + +1. `vi.hoisted()` + `vi.mock()` + static imports. Never `vi.resetModules()` + `vi.doMock()` + + dynamic `import()` — it re-evaluates the module graph per test. The only exception is a module + that caches a singleton at module scope. +2. Never `vi.importActual()` — it loads the real module and its whole graph. Mock explicitly. +3. Mock heavy graphs you do not need: `@/blocks`, `@/tools/registry`, `@/triggers/registry`, + `@/tools/generated/*`. +4. Node environment is the default — never write `@vitest-environment node`. Add + `/** @vitest-environment jsdom */` only when the test needs + `window`, `document`, or other DOM APIs. +5. No real timers: `vi.useFakeTimers()` or a 1ms delay. +6. Vitest clears mock call history before every test (`clearMocks` is on by default), so never + add `vi.clearAllMocks()` to a `beforeEach`. Reset implementations explicitly only where a test + installs a permanent one (`mockReturnValue`, not `mockReturnValueOnce`). +7. Absolute imports only. + +### Running + +From `apps/sim`: `../../node_modules/.bin/vitest run `. Other workspaces: run from the +workspace directory. `bunx vitest` fetches a different Vitest and fails to load the config. +Never pipe the runner through `grep`/`tail` in a script where the pipe hides its exit code. diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index 546190bcb62..3c20290b06a 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -31,10 +31,12 @@ jobs: with: bun-version: 1.4.1 + # Vitest 5 requires Node 22.12+; the published bundle is still + # smoke-tested on Node 20 below, its minimum supported runtime. - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: '20' + node-version: '22' - name: Cache Bun dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 @@ -120,6 +122,11 @@ jobs: echo "tag=$TAG" } >> "$GITHUB_OUTPUT" + - name: Setup Node 20 for the bundle smoke test + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '20' + - name: Smoke-test packed Node bundle working-directory: packages/sim-cli run: | diff --git a/.github/workflows/publish-sim-setup.yml b/.github/workflows/publish-sim-setup.yml index 19b667c17b6..fdf99ceb8fa 100644 --- a/.github/workflows/publish-sim-setup.yml +++ b/.github/workflows/publish-sim-setup.yml @@ -31,10 +31,12 @@ jobs: with: bun-version: 1.4.1 + # Vitest 5 requires Node 22.12+; the published bundle is still + # smoke-tested on Node 20 below, its minimum supported runtime. - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: '20' + node-version: '22' - name: Cache Bun dependencies uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 @@ -117,6 +119,11 @@ jobs: echo "tag=$TAG" } >> "$GITHUB_OUTPUT" + - name: Setup Node 20 for the bundle smoke test + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '20' + - name: Smoke-test packed Node bundle working-directory: packages/sim-setup run: | diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index aafa67327d2..7be5eb1002d 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -9,9 +9,12 @@ permissions: jobs: oauth-postgres: + # Runs the real-infrastructure test layer: every `*.integration.ts` in packages/db and + # apps/sim, discovered by glob (`vitest run --mode integration`), against the database each + # provisioning path produces. A new integration suite needs no workflow change. name: PostgreSQL integration (${{ matrix.provision }}) runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} - timeout-minutes: 15 + timeout-minutes: 25 strategy: fail-fast: false matrix: @@ -31,11 +34,11 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: sim_auth_scim + POSTGRES_DB: sim_test ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U postgres -d sim_auth_scim" + --health-cmd "pg_isready -U postgres -d sim_test" --health-interval 5s --health-timeout 5s --health-retries 10 @@ -44,17 +47,18 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: sim_billing_test + POSTGRES_DB: sim_test ports: - 5433:5432 options: >- - --health-cmd "pg_isready -U postgres -d sim_billing_test" + --health-cmd "pg_isready -U postgres -d sim_test" --health-interval 5s --health-timeout 5s --health-retries 10 env: - DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test + TEST_REDIS_URL: redis://127.0.0.1:6379 + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test BETTER_AUTH_SECRET: oauth-postgres-ci-secret-at-least-32-characters NEXT_PUBLIC_APP_URL: https://test.sim.ai ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' @@ -83,12 +87,6 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts - - name: Verify direct schema push compatibility - working-directory: packages/db - env: - DB_PUSH_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres - run: bunx vitest run scripts/push.postgres.test.ts - - name: Provision a fresh database through the supported command working-directory: packages/db run: | @@ -100,84 +98,36 @@ jobs: working-directory: packages/db run: bun run db:migrate - - name: Verify schema contract migrations in PostgreSQL + - name: Run packages/db integration tests working-directory: packages/db - env: - MIGRATION_CONTRACT_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: >- - bunx vitest run - scripts/retired-columns.postgres.test.ts - scripts/connector-sync-schedule-precision.postgres.test.ts - scripts/database-failure-classification.postgres.test.ts + run: bunx vitest run --mode integration - - name: Verify OAuth lifecycle and SCIM membership guards in PostgreSQL - working-directory: apps/sim - # These suites share a schema and install triggers; parallel files can deadlock DDL against cleanup. - run: >- - bunx vitest run --no-file-parallelism - lib/auth/oauth-token-family.postgres.test.ts - lib/auth/oauth-provider-lifecycle.postgres.test.ts - app/api/auth/oauth2/token/route.postgres.test.ts - lib/auth/sim-auth-adapter.test.ts - lib/auth/sim-auth-adapter.postgres.test.ts - ee/scim/lib/managed-membership.postgres.test.ts - lib/auth/sso/application/admit-sso-user.postgres.test.ts - lib/auth/sso/primary-provider.postgres.test.ts - - - name: Verify billing and organization activity in PostgreSQL + - name: Run apps/sim integration tests working-directory: apps/sim + # A non-UTC process zone keeps timestamp-without-time-zone handling honest. env: - BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - BILLING_USAGE_TEST_REDIS_URL: redis://127.0.0.1:6379 - run: >- - bunx vitest run - lib/billing/core/usage-log.postgres.test.ts - lib/billing/core/organization-activity.postgres.test.ts - lib/billing/core/usage-analytics-queries.postgres.test.ts - lib/billing/core/organization-usage-pagination.postgres.test.ts - lib/billing/organizations/member-limits.postgres.test.ts - lib/workspaces/organization-workspaces.postgres.test.ts - lib/billing/calculations/usage-reservation.test.ts - - - name: Verify access request flows, pagination, and impact in PostgreSQL - working-directory: apps/sim - env: - ACCESS_REQUESTS_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_access_requests_test - run: | - bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); await sql.unsafe("CREATE DATABASE sim_access_requests_test"); await sql.end()' - bunx vitest run ee/access-requests/lib/repository.postgres.test.ts ee/access-requests/lib/impact.postgres.test.ts ee/access-requests/lib/application/flow.postgres.test.ts - - - name: Verify fork previews ignore execution file history in PostgreSQL - working-directory: apps/sim - env: - FORK_REVISION_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: bunx vitest run ee/workspace-forking/application/revision.postgres.test.ts + TZ: America/Los_Angeles + run: bunx vitest run --mode integration - name: Verify cumulative billing timeout recovery on PostgreSQL 16 if: matrix.provision == 'push' working-directory: apps/sim env: - BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5433/sim_billing_test - run: bunx vitest run lib/billing/core/usage-log.postgres.test.ts - - - name: Verify file search dispatch deadlines on PostgreSQL 17 - working-directory: apps/sim - env: - TZ: America/Los_Angeles - KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: bunx vitest run --mode integration lib/workspace-files/search/dispatcher.integration.ts - - - name: Verify workspace file version history on PostgreSQL 17 - working-directory: apps/sim - env: - KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: bunx vitest run --mode integration lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts + TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5433/sim_test + run: >- + bunx vitest run --mode integration lib/billing/core/usage-log.integration.ts + --outputFile.json=test-results/integration-pg16.json - - name: Verify file search trigram estimate against pg_trgm - working-directory: apps/sim - env: - KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: bunx vitest run --mode integration lib/workspace-files/search/index-plan.integration.ts + - name: Upload integration test reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: integration-reports-${{ matrix.provision }} + path: | + packages/db/test-results/*.json + apps/sim/test-results/*.json + if-no-files-found: warn + retention-days: 14 - name: Verify SCIM and administration over real HTTP working-directory: apps/sim @@ -239,95 +189,6 @@ jobs: if-no-files-found: ignore retention-days: 7 - - name: Verify durable provenance, concurrent memory writes, and browser download admission - working-directory: apps/sim - env: - BROWSER_FILE_TRANSFER_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - TABLE_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - MEMORY_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - AGENT_MEMORY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: >- - bunx vitest run - lib/mothership/async-runs/browser-download-claim.postgres.test.ts - lib/table/rows/secret-provenance.postgres.test.ts - lib/memory/message-provenance.postgres.test.ts - lib/memory/conversation-store.postgres.test.ts - lib/memory/summary-store.postgres.test.ts - executor/handlers/agent/memory-harness.postgres.test.ts - - - name: Verify Search vector projection upgrade in PostgreSQL - working-directory: packages/db - env: - KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: >- - bunx vitest run - script-migrations/0016_backfill_search_vectors.postgres.test.ts - script-migrations/0018_repair_workspace_file_content_revision.postgres.test.ts - script-migrations/0019_tin_keyword_projection.postgres.test.ts - member-sync-status-migration.postgres.test.ts - - - name: Verify Search progress, pagination, and outbox scheduling in PostgreSQL - working-directory: apps/sim - env: - KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: >- - bunx vitest run --mode integration - lib/knowledge/__integration__/search-source-progress.integration.ts - lib/knowledge/__integration__/organization-search-overview.integration.ts - lib/knowledge/__integration__/search-source-pagination.integration.ts - lib/knowledge/__integration__/search-reference-batching.integration.ts - lib/knowledge/__integration__/embedding-insert-batches.integration.ts - lib/knowledge/__integration__/processing-lock-scope.integration.ts - lib/knowledge/__integration__/connector-lifecycle-locks.integration.ts - lib/knowledge/__integration__/connector-deferral.integration.ts - lib/knowledge/__integration__/stored-document-recovery.integration.ts - lib/knowledge/__integration__/connector-partition-work.integration.ts - lib/knowledge/__integration__/user-document-visibility.integration.ts - lib/knowledge/__integration__/listing-continuation.integration.ts - lib/knowledge/__integration__/member-scope-renewal.integration.ts - lib/knowledge/__integration__/member-document-lifecycle.integration.ts - lib/knowledge/__integration__/connector-lease-pages.integration.ts - lib/knowledge/__integration__/slack-empty-threads.integration.ts - lib/knowledge/__integration__/kb-block-search.integration.ts - lib/knowledge/__integration__/gitlab-workspace.integration.ts - lib/knowledge/__integration__/unfilled-projection-source.integration.ts - lib/knowledge/__integration__/knowledge-projection.integration.ts - lib/knowledge/__integration__/async-projection-processing.integration.ts - lib/knowledge/__integration__/purged-detach-reservation.integration.ts - lib/core/outbox/service.integration.ts - lib/knowledge/__integration__/connector-upload.integration.ts - lib/uploads/contexts/organization-logo/application.integration.ts - - - name: Verify Confluence identity and directory sync in PostgreSQL - working-directory: apps/sim - env: - KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim - run: >- - bunx vitest run --mode integration - lib/knowledge/__integration__/confluence-identity.integration.ts - lib/knowledge/__integration__/directory-sync.integration.ts - - - name: Verify Confluence audience migrations and permission queries in PostgreSQL - working-directory: apps/sim - env: - KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_acl_test - run: | - bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); await sql`CREATE DATABASE sim_acl_test`; await sql.end()' - bunx vitest run --mode integration lib/knowledge/access/group-membership.integration.ts - bunx vitest run \ - lib/knowledge/access/predicate.postgres.test.ts \ - lib/knowledge/connectors/external-directory.postgres.test.ts \ - lib/knowledge/connectors/sync-persistence.postgres.test.ts \ - lib/knowledge/connectors/sync-content-pass.postgres.test.ts - - - name: Verify the projection source and ACL trigger and backfill in PostgreSQL - working-directory: packages/db - env: - KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_acl_test - run: | - bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); const [row] = await sql`SELECT 1 FROM pg_database WHERE datname = ${"sim_acl_test"}`; if (!row) await sql`CREATE DATABASE sim_acl_test`; await sql.end()' - bunx vitest run script-migrations/0021_embedding_search_connector.postgres.test.ts - test-build: name: Lint and Test runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} @@ -499,12 +360,7 @@ jobs: run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep) # Runs the setup CLI's Bun tests plus each workspace's Vitest suite, - # without `--coverage`. See the Codecov note below. - # - # apps/sim runs only its first shard here; `test-shard` below runs the - # others. That suite is bound by the single Vite server thread that feeds - # every worker — wall time is flat from 4 to 13 workers — so a bigger - # runner buys nothing and each extra runner takes a proportional slice. + # without `--coverage`. - name: Run tests env: NODE_OPTIONS: '--no-warnings --max-old-space-size=8192' @@ -512,7 +368,6 @@ jobs: DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio' ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only TURBO_CACHE_DIR: .turbo - SIM_TEST_SHARD: 1/3 run: bun run test - name: Check schema and migrations are in sync @@ -528,72 +383,6 @@ jobs: fi echo "✅ Schema and migrations are in sync" - # The remaining shards of apps/sim's Vitest suite. Everything else — lint, - # the audits, type-check, the other workspaces' suites — lives in - # `test-build` with shard 1; these jobs exist only because that suite cannot - # go faster on one machine (see the "Run tests" note there). Three shards - # put each runner at roughly the fixed cost of checkout + install. The Turbo - # cache disk gets its own key so the shards' entries do not evict each other. - test-shard: - name: Test (shard ${{ matrix.shard }}) - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - shard: [2, 3] - - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.4.1 - - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: 24 - - - name: Mount Bun cache - uses: ./.github/actions/cache-mount - with: - provider: ${{ vars.CI_PROVIDER }} - key: ${{ github.repository }}-bun-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} - path: ~/.bun/install/cache - - - name: Mount node_modules - uses: ./.github/actions/cache-mount - with: - provider: ${{ vars.CI_PROVIDER }} - key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ hashFiles('bun.lock') }} - path: ./node_modules - - - name: Mount Turbo cache - uses: ./.github/actions/cache-mount - with: - provider: ${{ vars.CI_PROVIDER }} - key: ${{ github.repository }}-turbo-cache-shard-${{ matrix.shard }}-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} - path: ./.turbo - - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts - - - name: Install ripgrep - run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep) - - - name: Run tests (apps/sim shard ${{ matrix.shard }}/3) - env: - NODE_OPTIONS: '--no-warnings --max-old-space-size=8192' - NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' - DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio' - ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only - TURBO_CACHE_DIR: .turbo - SIM_TEST_SHARD: ${{ matrix.shard }}/3 - run: bunx turbo run test --filter=@sim/app - # Next.js production build, in parallel with lint + tests. Sticky disks are # cloned from the last committed snapshot per job and committed last-writer- # wins, so concurrent mounts are safe. The bun/node_modules disks are shared diff --git a/.gitignore b/.gitignore index 6bbecf8f295..920df52ee2c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ package-lock.json # testing /coverage /apps/**/coverage +.vitest/ +/apps/*/test-results +/packages/*/test-results # next.js /.next/ diff --git a/CLAUDE.md b/CLAUDE.md index 29ca6ff4ad1..1f3e36e6453 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -434,51 +434,15 @@ Principles when building or migrating shared UI: ## Testing -Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.claude/rules/sim-testing.md` for full details. +Most unit tests in a codebase like this restate the code they test. They pass on the first run, break on every refactor, and catch nothing that type-check, `next build`, `bun run check:audits`, or a real end-to-end run would miss. Test for confidence, not coverage. -### Global Mocks (vitest.setup.ts) +- **Never write unit tests after you write code.** A test written to describe code that already exists restates the implementation and proves nothing. If the change needs proof, prove it end to end. +- **Highly prefer E2E tests as the sole testing mechanism.** Use them to verify complex features work, against the real boundary: real Postgres/Redis (`*.integration.ts`), the running app over real HTTP (`apps/sim/scripts/test-*-e2e.ts`), or the packaged desktop app (`apps/desktop/e2e`, Playwright). At the end of an E2E test, produce a verifiable and repeatable artifact — a JSON report of each check with status and duration, an HTTP status log, a trace, or a screenshot — written to a caller-supplied `_REPORT_PATH` and uploaded by CI on failure. `apps/sim/scripts/test-scim-e2e.ts` is the reference. +- **If you must test a system in isolation, first write down all the ways it could fail, then write the code.** Each failure mode (bad input, boundary, concurrency, partial failure, permission denial, resource cap) becomes one test that fails before the code exists. +- A regression test must fail on the pre-fix code. Revert each guard of the fix and watch its test go red before you trust it. +- Never write tests that restate declarations (block/tool/provider config, registries, constants, schemas accepting valid input), assert that mocks were called, check rendered text or class names, or test mocks and factories themselves. -`@sim/db`, `@sim/db/schema`, `drizzle-orm`, `@sim/logger`, `@sim/platform-authz/workflow`, `@/blocks/registry`, `@/lib/auth`, `@/lib/auth/hybrid`, `@/lib/core/utils/request`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The `vi.mock('@/lib/auth', ...)` in the example below is an override of the global mock so `getSession` can be controlled per-test.) - -### Standard Test Pattern - -```typescript -/** - * @vitest-environment node - */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetSession } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - -import { GET } from '@/app/api/my-route/route' - -describe('my route', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - }) - it('returns data', async () => { ... }) -}) -``` - -### Performance Rules - -- **NEVER** use `vi.resetModules()` + `vi.doMock()` + `await import()` — use `vi.hoisted()` + `vi.mock()` + static imports -- **NEVER** use `vi.importActual()` — mock everything explicitly -- **NEVER** use `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` from `@sim/testing` — they use `vi.doMock()` internally -- **Mock heavy deps** (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them -- **Use `@vitest-environment node`** unless DOM APIs are needed (`window`, `document`, `FormData`) -- **Avoid real timers** — use 1ms delays or `vi.useFakeTimers()` - -Use `@sim/testing` mocks/factories over local test data. +Use the `test-audit` skill whenever you write, change, review, or sweep tests — it holds the authoring gate, the junk patterns, and the retention bar. Test layers, file naming, and Vitest mechanics (global mocks, `@sim/testing`, performance rules) are in `.claude/rules/sim-testing.md`. ## Caching diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 206f288200a..2ce2040a709 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -63,6 +63,6 @@ "react": "19.2.4", "react-dom": "19.2.4", "typescript": "^7.0.2", - "vitest": "^4.1.0" + "vitest": "^5.0.1" } } diff --git a/apps/desktop/src/main/account-data-generation.test.ts b/apps/desktop/src/main/account-data-generation.test.ts index 8062a0d2fa8..23ebebce55f 100644 --- a/apps/desktop/src/main/account-data-generation.test.ts +++ b/apps/desktop/src/main/account-data-generation.test.ts @@ -1,11 +1,4 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - unlinkSync, - writeFileSync, -} from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,7 +13,6 @@ import { initializeAccountDataRecovery, invalidateAccountDataOperations, isAccountDataTeardownRequired, - prepareAccountDataTeardownForQuit, retryAccountDataTeardown, runAccountDataMutation, waitForAccountDataMutations, @@ -56,22 +48,6 @@ describe('account data generation', () => { expect(isAccountDataTeardownRequired()).toBe(true) }) - it('fails closed and retries marker persistence before quit', () => { - const blockedParent = join(directory, 'blocked') - markerPath = join(blockedParent, 'teardown-required.json') - initializeAccountDataRecovery(markerPath) - writeFileSync(blockedParent, 'not a directory') - - expect(beginAccountDataTeardown('account', ORIGIN)).toBe(false) - expect(isAccountDataTeardownRequired()).toBe(false) - expect(prepareAccountDataTeardownForQuit()).toBe(true) - - unlinkSync(blockedParent) - mkdirSync(blockedParent) - expect(beginAccountDataTeardown('account', ORIGIN)).toBe(true) - expect(existsSync(markerPath)).toBe(true) - }) - it('does not erase data when the recovery marker cannot be written', async () => { const blockedParent = join(directory, 'blocked') markerPath = join(blockedParent, 'teardown-required.json') @@ -109,32 +85,6 @@ describe('account data generation', () => { expect(getAccountDataTeardownKind()).toBeNull() }) - it('keeps recovery gated until a retry clears every account store', async () => { - beginAccountDataTeardown('account', ORIGIN) - const failedClear = vi.fn(async () => { - throw new Error('keychain unavailable') - }) - const successfulClear = vi.fn(async () => {}) - - await expect( - retryAccountDataTeardown([ - { label: 'browser profile', clear: failedClear }, - { label: 'local filesystem grants', clear: successfulClear }, - ]) - ).resolves.toEqual(['browser profile']) - expect(existsSync(markerPath)).toBe(true) - expect(isAccountDataTeardownRequired()).toBe(true) - - await expect( - retryAccountDataTeardown([ - { label: 'browser profile', clear: successfulClear }, - { label: 'local filesystem grants', clear: successfulClear }, - ]) - ).resolves.toEqual([]) - expect(existsSync(markerPath)).toBe(false) - expect(isAccountDataTeardownRequired()).toBe(false) - }) - it('never downgrades or clears an account recovery marker for a server switch', () => { beginAccountDataTeardown('account', ORIGIN) beginAccountDataTeardown('deployment', ORIGIN) @@ -160,43 +110,6 @@ describe('account data generation', () => { }) }) - it('keeps deployment recovery armed when the server configuration commit fails', () => { - beginAccountDataTeardown('deployment', ORIGIN) - - expect(completeDeploymentScopedTeardown(() => false)).toBe(false) - expect(existsSync(markerPath)).toBe(true) - expect(isAccountDataTeardownRequired()).toBe(true) - }) - - it('keeps deployment recovery armed when the server configuration commit throws', () => { - beginAccountDataTeardown('deployment', ORIGIN) - - expect(() => - completeDeploymentScopedTeardown(() => { - throw new Error('disk unavailable') - }) - ).toThrow('disk unavailable') - expect(existsSync(markerPath)).toBe(true) - expect(isAccountDataTeardownRequired()).toBe(true) - }) - - it('reports successful completion of a deployment-scoped teardown', () => { - beginAccountDataTeardown('deployment', ORIGIN) - - expect(completeDeploymentScopedTeardown(() => true)).toBe(true) - expect(isAccountDataTeardownRequired()).toBe(false) - }) - - it('treats an unknown marker version as an untrusted account teardown', () => { - writeFileSync(markerPath, '{"version":3,"kind":"deployment","origin":"https://old.example"}') - - initializeAccountDataRecovery(markerPath) - - expect(getAccountDataTeardownKind()).toBe('account') - expect(getAccountDataTeardownOrigin()).toBeNull() - expect(prepareAccountDataTeardownForQuit()).toBe(false) - }) - it('waits for an admitted commit before teardown can clear its store', async () => { let releaseMutation: (() => void) | undefined const mutation = new Promise((resolve) => { diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts deleted file mode 100644 index 6816c745736..00000000000 --- a/apps/desktop/src/main/app-routes.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { newChatRoute, settingsRoute } from '@/main/app-routes' - -describe('app routes', () => { - it('derives the new-chat route from the last workspace route', () => { - expect(newChatRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/home') - expect(newChatRoute('/workspace/ws1/home?resource=r1')).toBe('/workspace/ws1/home') - expect(newChatRoute('/account')).toBe('/home') - expect(newChatRoute(undefined)).toBe('/home') - expect(newChatRoute('//evil.example')).toBe('/home') - }) - - it('derives the settings route from the last workspace route', () => { - expect(settingsRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/settings/desktop') - expect(settingsRoute('/account')).toBe('/home') - expect(settingsRoute(undefined)).toBe('/home') - expect(settingsRoute('//evil.example')).toBe('/home') - }) -}) diff --git a/apps/desktop/src/main/atomic-json-file.test.ts b/apps/desktop/src/main/atomic-json-file.test.ts index fdb1fce2c08..b95236083ab 100644 --- a/apps/desktop/src/main/atomic-json-file.test.ts +++ b/apps/desktop/src/main/atomic-json-file.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -19,14 +19,6 @@ describe('bounded file reads', () => { rmSync(directory, { recursive: true, force: true }) }) - it('reads the file through its opened handle', async () => { - const filePath = join(directory, 'store.json') - writeFileSync(filePath, 'bounded payload') - - await expect(readFileWithinLimit(filePath, 15)).resolves.toEqual(Buffer.from('bounded payload')) - expect(readFileWithinLimitSync(filePath, 15)).toEqual(Buffer.from('bounded payload')) - }) - it('rejects a file larger than the configured limit', async () => { const filePath = join(directory, 'store.json') writeFileSync(filePath, 'too large') @@ -34,14 +26,4 @@ describe('bounded file reads', () => { await expect(readFileWithinLimit(filePath, 8)).rejects.toBeInstanceOf(FileResourceLimitError) expect(() => readFileWithinLimitSync(filePath, 8)).toThrow(FileResourceLimitError) }) - - it('rejects non-file handles', async () => { - const childDirectory = join(directory, 'store') - mkdirSync(childDirectory) - - await expect(readFileWithinLimit(childDirectory, 100)).rejects.toBeInstanceOf( - FileResourceLimitError - ) - expect(() => readFileWithinLimitSync(childDirectory, 100)).toThrow(FileResourceLimitError) - }) }) diff --git a/apps/desktop/src/main/browser-agent/cdp.test.ts b/apps/desktop/src/main/browser-agent/cdp.test.ts index 4b24e3484df..821f9b956d4 100644 --- a/apps/desktop/src/main/browser-agent/cdp.test.ts +++ b/apps/desktop/src/main/browser-agent/cdp.test.ts @@ -14,17 +14,13 @@ import { import { captureScreenshot, clickAt, - consumeAgentContextMenu, dragPointer, ensureInstrumented, evaluateInIsolatedFrame, insertText, - movePointer, PRIMARY_CLICK, - pointerPathSteps, releaseFileInput, resolveFileInput, - setColorScheme, setFileInputFiles, } from '@/main/browser-agent/cdp' @@ -132,39 +128,6 @@ describe('browser-agent CDP instrumentation', () => { }) }) - it('accepts an OOPIF beforeunload dialog on the flattened child session', async () => { - const contents = new WebContentsView().webContents - const onDialog = vi.fn() - await ensureInstrumented(contents, { onDialog, dialogResponse: () => null }) - const listener = vi - .mocked(contents.debugger.on) - .mock.calls.find(([event]) => event === 'message')?.[1] as - | ((event: unknown, method: string, params: unknown, sessionId?: string) => void) - | undefined - expect(listener).toBeTypeOf('function') - vi.mocked(contents.debugger.sendCommand).mockClear() - - listener?.( - {}, - 'Page.javascriptDialogOpening', - { type: 'beforeunload', message: 'Leave this page?' }, - 'child-session' - ) - await vi.waitFor(() => expect(onDialog).toHaveBeenCalled()) - - expect(contents.debugger.sendCommand).toHaveBeenCalledWith( - 'Page.handleJavaScriptDialog', - { accept: true }, - 'child-session' - ) - expect(onDialog).toHaveBeenCalledWith({ - type: 'beforeunload', - message: 'Leave this page?', - handled: true, - accepted: true, - }) - }) - it('answers dialogs with the running action requested response', async () => { const contents = new WebContentsView().webContents const onDialog = vi.fn() @@ -194,141 +157,6 @@ describe('browser-agent CDP instrumentation', () => { }) }) - it('reports an OOPIF dialog as unhandled when child and root commands fail', async () => { - const contents = new WebContentsView().webContents - const onDialog = vi.fn() - await ensureInstrumented(contents, { onDialog, dialogResponse: () => null }) - const listener = vi - .mocked(contents.debugger.on) - .mock.calls.find(([event]) => event === 'message')?.[1] as - | ((event: unknown, method: string, params: unknown, sessionId?: string) => void) - | undefined - expect(listener).toBeTypeOf('function') - vi.mocked(contents.debugger.sendCommand).mockClear() - vi.mocked(contents.debugger.sendCommand).mockRejectedValue(new Error('dialog target closed')) - - listener?.( - {}, - 'Page.javascriptDialogOpening', - { type: 'confirm', message: 'Continue?' }, - 'child-session' - ) - await vi.waitFor(() => expect(onDialog).toHaveBeenCalled()) - - expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([ - ['Page.handleJavaScriptDialog', { accept: false }, 'child-session'], - ['Page.handleJavaScriptDialog', { accept: false }], - ]) - expect(onDialog).toHaveBeenCalledWith({ - type: 'confirm', - message: 'Continue?', - handled: false, - accepted: false, - }) - }) - - it('clicks through Chromium trusted mouse input', async () => { - const contents = new WebContentsView().webContents - - await clickAt(contents, 120, 240) - - expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([ - ['Input.dispatchMouseEvent', { type: 'mouseMoved', x: 120, y: 240, button: 'none' }], - [ - 'Input.dispatchMouseEvent', - { - type: 'mousePressed', - x: 120, - y: 240, - button: 'left', - buttons: 1, - modifiers: 0, - clickCount: 1, - }, - ], - [ - 'Input.dispatchMouseEvent', - { - type: 'mouseReleased', - x: 120, - y: 240, - button: 'left', - buttons: 0, - modifiers: 0, - clickCount: 1, - }, - ], - ]) - }) - - it('stops a pointer route as soon as it is aborted', async () => { - const contents = new WebContentsView().webContents - const moves = () => - vi - .mocked(contents.debugger.sendCommand) - .mock.calls.filter(([method]) => String(method).startsWith('Input.dispatchMouseEvent')) - const aborted = new AbortController() - aborted.abort() - await expect( - movePointer(contents, { via: [], durationMs: null }, { x: 5, y: 5 }, aborted.signal) - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(moves()).toHaveLength(0) - - vi.useFakeTimers() - try { - const controller = new AbortController() - const route = movePointer( - contents, - { via: [{ x: 0, y: 0 }], durationMs: 5_000 }, - { x: 500, y: 0 }, - controller.signal - ) - const settled = expect(route).rejects.toMatchObject({ name: 'AbortError' }) - await vi.advanceTimersByTimeAsync(100) - const sentBeforeAbort = moves().length - controller.abort() - await settled - expect(moves()).toHaveLength(sentBeforeAbort) - } finally { - vi.useRealTimers() - } - }) - - it('sends nothing for an already-aborted drag and cancels one aborted while it settles', async () => { - const contents = new WebContentsView().webContents - const mouse = () => - vi - .mocked(contents.debugger.sendCommand) - .mock.calls.filter(([method]) => method === 'Input.dispatchMouseEvent') - .map(([, params]) => toRecord(params).type) - const aborted = new AbortController() - aborted.abort() - await expect( - dragPointer(contents, { x: 0, y: 0 }, { x: 50, y: 0 }, undefined, aborted.signal) - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(contents.debugger.sendCommand).not.toHaveBeenCalled() - - vi.useFakeTimers() - try { - const controller = new AbortController() - const drag = dragPointer( - contents, - { x: 0, y: 0 }, - { x: 50, y: 0 }, - undefined, - controller.signal - ) - const settled = expect(drag).rejects.toMatchObject({ name: 'AbortError' }) - // The default route takes 13 moves 20 ms apart, then a 120 ms settle hold. - await vi.advanceTimersByTimeAsync(300) - controller.abort() - await settled - expect(mouse().at(-1)).toBe('mouseReleased') - } finally { - vi.useRealTimers() - } - }) - it('releases the button when a timed drag is aborted mid-route', async () => { const contents = new WebContentsView().webContents const types = () => @@ -357,55 +185,6 @@ describe('browser-agent CDP instrumentation', () => { } }) - it('keeps the default drag pace and lands exactly on every via point', () => { - const direct = pointerPathSteps({ x: 0, y: 0 }, { via: [], durationMs: null }, { x: 120, y: 0 }) - expect(direct.stepDelayMs).toBe(20) - expect(direct.points).toHaveLength(12) - expect(direct.points[0]).toEqual({ x: 10, y: 0 }) - expect(direct.points[11]).toEqual({ x: 120, y: 0 }) - - const routed = pointerPathSteps( - { x: 0, y: 0 }, - { via: [{ x: 100, y: 0 }], durationMs: 800 }, - { x: 100, y: 300 } - ) - expect(routed.points).toContainEqual({ x: 100, y: 0 }) - expect(routed.points[routed.points.length - 1]).toEqual({ x: 100, y: 300 }) - expect(routed.points.length * routed.stepDelayMs).toBeCloseTo(800) - // The longer second segment gets about three times the steps of the first. - const corner = routed.points.findIndex((point) => point.x === 100 && point.y === 0) - expect(routed.points.length - 1 - corner).toBeGreaterThan(corner * 2) - }) - - it('holds the button down for holdMs before releasing it', async () => { - const contents = new WebContentsView().webContents - const types = () => - vi.mocked(contents.debugger.sendCommand).mock.calls.map(([, params]) => toRecord(params).type) - vi.useFakeTimers() - try { - const click = clickAt(contents, 5, 6, false, { ...PRIMARY_CLICK, holdMs: 1500 }) - await vi.advanceTimersByTimeAsync(1000) - expect(types()).toEqual(['mousePressed']) - - await vi.advanceTimersByTimeAsync(500) - await click - expect(types()).toEqual(['mousePressed', 'mouseReleased']) - } finally { - vi.useRealTimers() - } - }) - - it('presses nothing when its click was aborted before dispatch', async () => { - const contents = new WebContentsView().webContents - const controller = new AbortController() - controller.abort() - - await expect( - clickAt(contents, 5, 6, true, PRIMARY_CLICK, controller.signal) - ).rejects.toMatchObject({ name: 'AbortError' }) - expect(contents.debugger.sendCommand).not.toHaveBeenCalled() - }) - it('releases a held button as soon as its click is aborted', async () => { const contents = new WebContentsView().webContents const types = () => @@ -426,28 +205,6 @@ describe('browser-agent CDP instrumentation', () => { } }) - it('keeps a held right-click marked as the agent context menu until release', async () => { - const contents = new WebContentsView().webContents - vi.useFakeTimers() - try { - const rightHold = { ...PRIMARY_CLICK, button: 'right' as const, holdMs: 1500 } - await Promise.all([ - clickAt(contents, 5, 6, false, rightHold), - vi.advanceTimersByTimeAsync(1500), - ]) - expect(consumeAgentContextMenu(contents)).toBe(true) - - const click = clickAt(contents, 5, 6, false, rightHold) - await vi.advanceTimersByTimeAsync(0) - expect(consumeAgentContextMenu(contents)).toBe(true) - await vi.advanceTimersByTimeAsync(1500) - await click - expect(consumeAgentContextMenu(contents)).toBe(false) - } finally { - vi.useRealTimers() - } - }) - it('releases the mouse after a partial click failure', async () => { const contents = new WebContentsView().webContents vi.mocked(contents.debugger.sendCommand) @@ -472,44 +229,6 @@ describe('browser-agent CDP instrumentation', () => { ]) }) - it('best-effort releases the mouse when the press response is lost', async () => { - const contents = new WebContentsView().webContents - vi.mocked(contents.debugger.sendCommand) - .mockResolvedValueOnce({}) - .mockRejectedValueOnce(new Error('mouse press response lost')) - .mockRejectedValueOnce(new Error('cleanup unavailable')) - - await expect(clickAt(contents, 36, 48)).rejects.toThrow('mouse press response lost') - - expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([ - ['Input.dispatchMouseEvent', { type: 'mouseMoved', x: 36, y: 48, button: 'none' }], - [ - 'Input.dispatchMouseEvent', - { - type: 'mousePressed', - x: 36, - y: 48, - button: 'left', - buttons: 1, - modifiers: 0, - clickCount: 1, - }, - ], - [ - 'Input.dispatchMouseEvent', - { - type: 'mouseReleased', - x: 36, - y: 48, - button: 'left', - buttons: 0, - modifiers: 0, - clickCount: 1, - }, - ], - ]) - }) - it('times out a hung press and sends cleanup before the tool watchdog can release', async () => { vi.useFakeTimers() try { @@ -909,19 +628,6 @@ describe('browser-agent file input handles', () => { ).toHaveLength(1) }) - it('supports a same-origin child document and XHTML input captured by its parent world', async () => { - const { contents, frame, input } = await fileInputFixture() - input.tagName = 'input' - const handle = await resolveFileInput(contents, frame, 'captureSameOriginChildInput()') - try { - await expect(setFileInputFiles(contents, handle, ['/staged/a.pdf'])).resolves.toEqual({ - files: [{ name: 'a.pdf', size: 12 }], - }) - } finally { - await releaseFileInput(contents, handle) - } - }) - it.each(['evaluation', 'metadata'] as const)('releases handles when %s fails', async (phase) => { const { contents, frame, input, behavior, send } = await fileInputFixture() if (phase === 'evaluation') behavior.rejectEvaluation = true @@ -934,64 +640,6 @@ describe('browser-agent file input handles', () => { expect(released).toEqual(phase === 'evaluation' ? ['exception'] : ['exception', 'wrapper']) }) - it('releases the transient node when cancellation arrives during validation', async () => { - const { contents, frame, behavior, send } = await fileInputFixture() - const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)') - const controller = new AbortController() - const onDispatch = vi.fn() - behavior.afterInputValidation = () => controller.abort() - try { - await expect( - setFileInputFiles(contents, handle, ['/staged/a.pdf'], controller.signal, onDispatch) - ).rejects.toThrow() - expect(send.mock.calls.some(([method]) => method === 'DOM.setFileInputFiles')).toBe(false) - expect(onDispatch).not.toHaveBeenCalled() - } finally { - await releaseFileInput(contents, handle) - } - expect(send).toHaveBeenCalledWith('Runtime.releaseObject', { objectId: 'original-input' }) - }) - - it('releases the transient node when Chromium rejects the file assignment', async () => { - const { contents, frame, behavior, send } = await fileInputFixture(true) - const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)') - behavior.rejectSet = true - const onDispatch = vi.fn() - try { - await expect( - setFileInputFiles(contents, handle, ['/staged/a.pdf'], undefined, onDispatch) - ).rejects.toThrow('disappeared') - expect(onDispatch.mock.calls).toEqual([['pending']]) - } finally { - await releaseFileInput(contents, handle) - } - expect(send).toHaveBeenCalledWith( - 'Runtime.releaseObject', - { objectId: 'original-input' }, - 'child-session' - ) - expect(send).toHaveBeenCalledWith( - 'Runtime.releaseObject', - { objectId: 'wrapper' }, - 'child-session' - ) - }) - - it('reads the original input even when its change handler removes it', async () => { - const { contents, frame, input, behavior } = await fileInputFixture() - const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)') - behavior.afterSet = () => { - input.isConnected = false - } - try { - await expect(setFileInputFiles(contents, handle, ['/staged/a.pdf'])).resolves.toEqual({ - files: [{ name: 'a.pdf', size: 12 }], - }) - } finally { - await releaseFileInput(contents, handle) - } - }) - it('reports pending dispatch while acknowledgement is held, then acknowledges before readback', async () => { const { contents, frame, behavior, send } = await fileInputFixture() const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)') @@ -1025,51 +673,6 @@ describe('browser-agent file input handles', () => { await releaseFileInput(contents, handle) } }) - - it('reports readback failure separately once Chromium has acknowledged the upload', async () => { - const { contents, frame, behavior, send } = await fileInputFixture() - const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)') - behavior.rejectReadback = true - try { - await expect(setFileInputFiles(contents, handle, ['/staged/a.pdf'])).resolves.toEqual({ - readbackError: 'Execution context was destroyed', - }) - } finally { - await releaseFileInput(contents, handle) - } - expect(send.mock.calls.filter(([method]) => method === 'DOM.setFileInputFiles')).toHaveLength(1) - expect(send).toHaveBeenCalledWith('Runtime.releaseObject', { objectId: 'original-input' }) - }) -}) - -describe('browser-agent CDP theme', () => { - it('emulates explicit light and dark preferences', async () => { - const contents = new WebContentsView().webContents - - await setColorScheme(contents, 'dark') - await setColorScheme(contents, 'light') - - expect(vi.mocked(contents.debugger.sendCommand).mock.calls).toEqual([ - [ - 'Emulation.setEmulatedMedia', - { features: [{ name: 'prefers-color-scheme', value: 'dark' }] }, - ], - [ - 'Emulation.setEmulatedMedia', - { features: [{ name: 'prefers-color-scheme', value: 'light' }] }, - ], - ]) - }) - - it('clears the override for the system preference', async () => { - const contents = new WebContentsView().webContents - - await setColorScheme(contents, 'system') - - expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Emulation.setEmulatedMedia', { - features: [], - }) - }) }) /** @@ -1122,43 +725,6 @@ describe('browser-agent screenshot capture', () => { ) }) - it('crops the decoded image in memory without sending a CDP clip', async () => { - const { contents, cropped, image } = captureFixture({ width: 4096, height: 2048 }) - - const shot = await captureScreenshot(contents, { x: 100, y: 50, width: 200, height: 100 }) - - expect(contents.capturePage).toHaveBeenCalledWith(undefined, { stayHidden: true }) - expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith( - 'Page.captureScreenshot', - expect.anything() - ) - expect(image.crop).toHaveBeenCalledWith({ x: 200, y: 100, width: 400, height: 200 }) - expect(cropped.resize).not.toHaveBeenCalled() - expect(shot).toEqual({ - dataUrl: `data:image/jpeg;base64,${Buffer.from('cropped').toString('base64')}`, - scale: 2, - viewport: { width: 2048, height: 1024 }, - imageSize: { width: 400, height: 200 }, - clip: { x: 100, y: 50, width: 200, height: 100 }, - }) - }) - - it('reports the actual CSS crop after rounding a narrow fractional element to pixels', async () => { - const { contents, cropped, image } = captureFixture({ width: 4096, height: 2048 }) - cropped.getSize.mockReturnValue({ width: 3, height: 201 }) - - const shot = await captureScreenshot(contents, { x: 0.1, y: 0.2, width: 1.1, height: 100 }) - - expect(image.crop).toHaveBeenCalledWith({ x: 0, y: 0, width: 3, height: 201 }) - expect(shot).toMatchObject({ - clip: { x: 0, y: 0, width: 1.5, height: 100.5 }, - imageSize: { width: 3, height: 201 }, - scale: 2, - }) - expect(100 / shot.scale).toBe(50) - expect(cropped.resize).not.toHaveBeenCalled() - }) - it.each([ { requested: { x: -10, y: -20, width: 30, height: 40 }, @@ -1187,46 +753,6 @@ describe('browser-agent screenshot capture', () => { } ) - /** - * A 2048px CSS viewport bounded to 1024px is scale 0.5, and the capture - * arrives at device resolution (4096px on a 2x display). The resize is what - * lands the image on the CSS-relative size the coordinate contract - * (cssX = imageX / scale) assumes. - */ - it('downscales the returned image to the CSS-relative size', async () => { - const { contents, resized, image } = captureFixture({ width: 4096, height: 2048 }) - - const shot = await captureScreenshot(contents) - - expect(image.resize).toHaveBeenCalledWith({ width: 1024, height: 512, quality: 'good' }) - expect(resized.toJPEG).toHaveBeenCalled() - expect(shot).toEqual({ - dataUrl: `data:image/jpeg;base64,${Buffer.from('resized').toString('base64')}`, - scale: 0.5, - viewport: { width: 2048, height: 1024 }, - imageSize: { width: 1024, height: 512 }, - }) - }) - - it('skips resizing when the capture already matches the target size', async () => { - const { contents, image } = captureFixture({ width: 1024, height: 512 }) - - const shot = await captureScreenshot(contents) - - expect(image.resize).not.toHaveBeenCalled() - expect(shot).toEqual({ - dataUrl: 'data:image/jpeg;base64,c2lt', - scale: 0.5, - viewport: { width: 2048, height: 1024 }, - imageSize: { width: 1024, height: 512 }, - }) - }) - - it('rejects an empty native capture', async () => { - const { contents } = captureFixture(null) - await expect(captureScreenshot(contents)).rejects.toThrow('empty image') - }) - describe('stalled native capture recovery', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) @@ -1293,151 +819,6 @@ describe('browser-agent screenshot capture', () => { expect(contents.endFrameSubscription).toHaveBeenCalledTimes(2) expect(vi.getTimerCount()).toBe(0) }) - - it('ignores a timed-out frame callback while a later subscription is active', async () => { - const { contents, image } = captureFixture({ width: 1024, height: 512 }, 'fresh') - const stale = captureFixture({ width: 1024, height: 512 }, 'stale').image - vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) - const frames = observeFrames(contents) - const failed = expect(captureScreenshot(contents)).rejects.toThrow('frame capture timed out') - await vi.advanceTimersByTimeAsync(10_000) - await failed - - const recovered = captureScreenshot(contents) - const settled = vi.fn() - void recovered.then(settled) - await vi.advanceTimersByTimeAsync(0) - frames[0](stale) - await vi.advanceTimersByTimeAsync(0) - expect(settled).not.toHaveBeenCalled() - expect(contents.endFrameSubscription).toHaveBeenCalledOnce() - frames[1](image) - await expect(recovered).resolves.toMatchObject({ - dataUrl: `data:image/jpeg;base64,${Buffer.from('fresh').toString('base64')}`, - }) - expect(contents.endFrameSubscription).toHaveBeenCalledTimes(2) - }) - - it.each(['resolve', 'reject'] as const)( - 'ignores a late native %s and resumes native captures afterward', - async (outcome) => { - const { contents, image } = captureFixture({ width: 1024, height: 512 }, 'current') - const stale = captureFixture({ width: 1024, height: 512 }, 'stale').image - let settleNative: () => void = () => {} - vi.mocked(contents.capturePage).mockImplementationOnce( - () => - new Promise((resolve, reject) => { - settleNative = () => - outcome === 'resolve' ? resolve(stale) : reject(new Error('late failure')) - }) - ) - const frames = observeFrames(contents) - const capture = captureScreenshot(contents) - const settled = vi.fn() - void capture.then(settled) - await vi.advanceTimersByTimeAsync(5_000) - settleNative() - await vi.advanceTimersByTimeAsync(0) - expect(settled).not.toHaveBeenCalled() - expect(contents.endFrameSubscription).not.toHaveBeenCalled() - frames[0](image) - await expect(capture).resolves.toMatchObject({ - dataUrl: `data:image/jpeg;base64,${Buffer.from('current').toString('base64')}`, - }) - await expect(captureScreenshot(contents)).resolves.toMatchObject({ - dataUrl: `data:image/jpeg;base64,${Buffer.from('current').toString('base64')}`, - }) - expect(contents.capturePage).toHaveBeenCalledTimes(2) - expect(contents.beginFrameSubscription).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - } - ) - - it('rejects concurrent captures without replacing the active subscription or blocking another tab', async () => { - const { contents, image } = captureFixture({ width: 1024, height: 512 }) - const other = captureFixture({ width: 1024, height: 512 }) - vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) - const frames = observeFrames(contents) - const capture = captureScreenshot(contents) - await vi.advanceTimersByTimeAsync(0) - await expect(captureScreenshot(contents)).rejects.toThrow('already in progress') - expect(contents.capturePage).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(5_000) - await expect(captureScreenshot(contents)).rejects.toThrow('already in progress') - expect(contents.beginFrameSubscription).toHaveBeenCalledOnce() - expect(contents.endFrameSubscription).not.toHaveBeenCalled() - await expect(captureScreenshot(other.contents)).resolves.toMatchObject({ - imageSize: { width: 1024, height: 512 }, - }) - frames[0](image) - await capture - expect(contents.endFrameSubscription).toHaveBeenCalledOnce() - }) - - it.each(['cancel', 'destroy'] as const)( - 'releases frame resources on %s and ignores a subsequent frame', - async (reason) => { - const { contents, image } = captureFixture({ width: 1024, height: 512 }) - vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) - const frames = observeFrames(contents) - const controller = new AbortController() - const removeAbort = vi.spyOn(controller.signal, 'removeEventListener') - const failed = expect( - captureScreenshot(contents, undefined, controller.signal) - ).rejects.toThrow(reason === 'cancel' ? 'cancelled' : 'tab was closed') - await vi.advanceTimersByTimeAsync(5_000) - const destroyed = vi - .mocked(contents.once) - .mock.calls.filter(([event]) => String(event) === 'destroyed') - .at(-1)?.[1] as unknown as (() => void) | undefined - expect(destroyed).toBeDefined() - if (reason === 'cancel') controller.abort() - else { - vi.mocked(contents.isDestroyed).mockReturnValue(true) - destroyed?.() - } - await failed - expect(contents.removeListener).toHaveBeenCalledWith('destroyed', destroyed) - expect(removeAbort).toHaveBeenCalledTimes(2) - expect(contents.endFrameSubscription).toHaveBeenCalledTimes(reason === 'cancel' ? 1 : 0) - frames[0](image) - await vi.advanceTimersByTimeAsync(0) - expect(contents.endFrameSubscription).toHaveBeenCalledTimes(reason === 'cancel' ? 1 : 0) - expect(vi.getTimerCount()).toBe(0) - if (reason === 'cancel') { - const recovered = captureScreenshot(contents) - await vi.advanceTimersByTimeAsync(0) - frames[1](image) - await recovered - expect(contents.capturePage).toHaveBeenCalledOnce() - expect(contents.endFrameSubscription).toHaveBeenCalledTimes(2) - } - } - ) - - it.each(['beginFrameSubscription', 'invalidate'] as const)( - 'cleans up a synchronous %s failure and permits another frame attempt', - async (method) => { - const { contents, image } = captureFixture({ width: 1024, height: 512 }) - vi.mocked(contents.capturePage).mockReturnValue(new Promise(() => {})) - const frames = observeFrames(contents) - vi.mocked(contents[method]).mockImplementationOnce(() => { - throw new Error('frame setup failed') - }) - const failed = expect(captureScreenshot(contents)).rejects.toThrow('frame setup failed') - await vi.advanceTimersByTimeAsync(5_000) - await failed - expect(contents.endFrameSubscription).toHaveBeenCalledOnce() - expect(vi.getTimerCount()).toBe(0) - - const recovered = captureScreenshot(contents) - await vi.advanceTimersByTimeAsync(0) - frames.at(-1)?.(image) - await expect(recovered).resolves.toMatchObject({ imageSize: { width: 1024, height: 512 } }) - expect(contents.capturePage).toHaveBeenCalledOnce() - expect(contents.endFrameSubscription).toHaveBeenCalledTimes(2) - } - ) }) it.each(['cancel', 'destroy'] as const)( @@ -1469,21 +850,6 @@ describe('browser-agent screenshot capture', () => { } ) - it('does not start capture after cancellation or keep a synchronous failure pending', async () => { - const { contents } = captureFixture({ width: 1024, height: 512 }) - const controller = new AbortController() - controller.abort() - await expect(captureScreenshot(contents, undefined, controller.signal)).rejects.toThrow() - expect(contents.capturePage).not.toHaveBeenCalled() - vi.mocked(contents.capturePage).mockImplementationOnce(() => { - throw new Error('native failure') - }) - await expect(captureScreenshot(contents)).rejects.toThrow('native failure') - await expect(captureScreenshot(contents)).resolves.toMatchObject({ - imageSize: { width: 1024, height: 512 }, - }) - }) - it('does not expose deprecated device-pixel metrics as a CSS viewport', async () => { const { contents } = captureFixture({ width: 1024, height: 512 }) vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { @@ -1499,46 +865,6 @@ describe('browser-agent screenshot capture', () => { expect(shot.imageSize).toEqual({ width: 1024, height: 512 }) }) - it('refuses element cropping without verified CSS viewport metrics', async () => { - const { contents } = captureFixture({ width: 1024, height: 512 }) - vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { - if (method === 'Page.getLayoutMetrics') { - return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) - } - return Promise.resolve(undefined) - }) - - await expect( - captureScreenshot(contents, { x: 10, y: 10, width: 100, height: 50 }) - ).rejects.toThrow(/CSS viewport/) - expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith( - 'Page.captureScreenshot', - expect.anything() - ) - }) - - it('accepts stable finite scroll offsets around the capture', async () => { - const { contents } = captureFixture({ width: 1024, height: 512 }) - vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { - if (method === 'Page.getLayoutMetrics') { - return Promise.resolve({ - cssLayoutViewport: { - clientWidth: 2048, - clientHeight: 1024, - pageX: 12, - pageY: 34, - }, - }) - } - return Promise.resolve(undefined) - }) - - await expect(captureScreenshot(contents)).resolves.toMatchObject({ - viewport: { width: 2048, height: 1024 }, - imageSize: { width: 1024, height: 512 }, - }) - }) - it.each([ [ 'dimensions', diff --git a/apps/desktop/src/main/browser-agent/context-menu.test.ts b/apps/desktop/src/main/browser-agent/context-menu.test.ts index 0ab0c6cd395..44058879f89 100644 --- a/apps/desktop/src/main/browser-agent/context-menu.test.ts +++ b/apps/desktop/src/main/browser-agent/context-menu.test.ts @@ -64,49 +64,6 @@ const item = (template: MenuItemConstructorOptions[], label: string) => template.find((entry) => entry.label === label) describe('buildAgentContextMenuTemplate', () => { - it('always offers navigation and zoom, whatever was clicked', () => { - const template = buildAgentContextMenuTemplate(params(), page(), handlers()) - - // Unlike the main window's menu, an empty template is not an option here: - // the page has no menu of its own to fall back to. - expect(labels(template)).toEqual([ - 'Back', - 'Forward', - 'Reload', - 'Zoom In', - 'Zoom Out', - 'Actual Size (100%)', - ]) - }) - - it('offers clipboard items only where the click can use them', () => { - expect(labels(buildAgentContextMenuTemplate(params(), page(), handlers()))).not.toContain( - 'Copy' - ) - - const withSelection = buildAgentContextMenuTemplate( - params({ selectionText: ' hello ' }), - page(), - handlers() - ) - expect(labels(withSelection)).toContain('Copy') - - const inField = buildAgentContextMenuTemplate( - params({ isEditable: true, editFlags: { ...EDIT_FLAGS, canPaste: true } }), - page(), - handlers() - ) - expect(labels(inField)).toContain('Paste') - - // A read-only field can report canPaste; both signals have to agree. - const readOnly = buildAgentContextMenuTemplate( - params({ isEditable: false, editFlags: { ...EDIT_FLAGS, canPaste: true } }), - page(), - handlers() - ) - expect(labels(readOnly)).not.toContain('Paste') - }) - it('puts Add to chat first and preserves the exact nonblank selection', () => { const handled = handlers() const template = buildAgentContextMenuTemplate( @@ -122,95 +79,6 @@ describe('buildAgentContextMenuTemplate', () => { labels(buildAgentContextMenuTemplate(params({ selectionText: ' \n ' }), page(), handlers())) ).not.toContain('Add to chat') }) - - it('offers link items for http(s) targets only', () => { - const handled = handlers() - const template = buildAgentContextMenuTemplate( - params({ linkURL: 'https://example.com/docs' }), - page(), - handled - ) - expect(labels(template)).toContain('Open Link in New Tab') - - item(template, 'Open Link in New Tab')?.click?.({} as never, undefined as never, {} as never) - expect(handled.openTab).toHaveBeenCalledWith('https://example.com/docs') - - // The actions open a tab or copy an address; neither means anything for a - // script or mail target, so the menu must not offer them. - for (const linkURL of ['javascript:alert(1)', 'mailto:a@b.com', 'file:///etc/passwd']) { - const other = buildAgentContextMenuTemplate(params({ linkURL }), page(), handlers()) - expect(labels(other)).not.toContain('Open Link in New Tab') - expect(labels(other)).not.toContain('Copy Link Address') - } - }) - - it('disables navigation the page cannot do', () => { - const template = buildAgentContextMenuTemplate( - params(), - page({ canGoBack: false, canGoForward: false }), - handlers() - ) - - expect(item(template, 'Back')?.enabled).toBe(false) - expect(item(template, 'Forward')?.enabled).toBe(false) - expect(item(template, 'Reload')?.enabled).toBeUndefined() - }) - - it('reports the current zoom and disables the ends of the ladder', () => { - // Two rungs up from the baseline, reported against the baseline rather than - // against Chromium's native scale (where this factor would read 110%). - const twoUp = steppedZoomFactor(steppedZoomFactor(BASE_ZOOM_FACTOR, 1), 1) - const stepped = buildAgentContextMenuTemplate(params(), page({ zoomFactor: twoUp }), handlers()) - expect(item(stepped, 'Actual Size (121%)')?.enabled).toBe(true) - - const atMax = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 3 }), handlers()) - expect(item(atMax, 'Zoom In')?.enabled).toBe(false) - expect(item(atMax, 'Zoom Out')?.enabled).toBe(true) - - const atMin = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 0.5 }), handlers()) - expect(item(atMin, 'Zoom Out')?.enabled).toBe(false) - - // Nothing to reset to at 100%. - expect( - item(buildAgentContextMenuTemplate(params(), page(), handlers()), 'Actual Size (100%)') - ?.enabled - ).toBe(false) - }) - - it('resets to the configured default, undoing accumulated drift', () => { - const handled = handlers() - // Three rungs of float multiplication up, so the factor no longer sits on a - // clean value — reset has to restore the baseline exactly, not step back. - const drifted = [1, 1, 1].reduce((factor) => steppedZoomFactor(factor, 1), BASE_ZOOM_FACTOR) - const configuredDefault = BASE_ZOOM_FACTOR * 1.25 - const template = buildAgentContextMenuTemplate( - params(), - page({ zoomFactor: drifted, defaultZoomFactor: configuredDefault }), - handled - ) - - item(template, 'Actual Size (133%)')?.click?.({} as never, undefined as never, {} as never) - - expect(handled.setZoomFactor).toHaveBeenCalledWith(configuredDefault) - }) - - it('never leaves a separator with nothing above it', () => { - for (const p of [ - params(), - params({ selectionText: 'hi' }), - params({ linkURL: 'https://example.com' }), - params({ isEditable: true, editFlags: { ...EDIT_FLAGS, canPaste: true } }), - ]) { - const template = buildAgentContextMenuTemplate(p, page(), handlers()) - expect(template[0].type).not.toBe('separator') - expect(template[template.length - 1].type).not.toBe('separator') - expect( - template.some( - (entry, index) => entry.type === 'separator' && template[index - 1]?.type === 'separator' - ) - ).toBe(false) - } - }) }) describe('attachAgentContextMenu', () => { @@ -260,66 +128,13 @@ describe('attachAgentContextMenu', () => { expect(Menu.buildFromTemplate).toHaveBeenCalledTimes(1) } ) - - it('pops a menu built from the page that was right-clicked', () => { - const contents = new WebContentsView().webContents - vi.mocked(contents.navigationHistory.canGoBack).mockReturnValue(true) - attachAgentContextMenu(contents, { - addToChat: vi.fn(), - openTab: vi.fn(), - defaultZoomFactor: () => BASE_ZOOM_FACTOR, - }) - - const listeners = vi.mocked(contents.on).mock.calls as unknown as [ - string, - ContextMenuListener, - ][] - const onContextMenu = listeners.find(([event]) => event === 'context-menu')?.[1] - expect(onContextMenu).toBeDefined() - onContextMenu?.({}, params()) - - const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as - | MenuItemConstructorOptions[] - | undefined - // The template is read off the live page, not a snapshot of it. - expect(item(template ?? [], 'Back')?.enabled).toBe(true) - expect(item(template ?? [], 'Forward')?.enabled).toBe(false) - }) }) describe('steppedZoomFactor', () => { - it('steps up and down from the current factor', () => { - expect(steppedZoomFactor(1, 1)).toBe(1.1) - expect(steppedZoomFactor(1, -1)).toBe(1 / 1.1) - }) - it('clamps at both ends so a step never runs away', () => { expect(steppedZoomFactor(3, 1)).toBe(3) expect(steppedZoomFactor(0.5, -1)).toBe(0.5) }) - - it('treats a nonsense factor as the baseline', () => { - // One rung up from the baseline is Chromium's native 1.0. - expect(steppedZoomFactor(Number.NaN, 1)).toBe(1) - expect(steppedZoomFactor(0, 1)).toBe(1) - }) -}) - -describe('BASE_ZOOM_FACTOR', () => { - it('renders a rung below native but reads as 100%', () => { - expect(BASE_ZOOM_FACTOR).toBeCloseTo(0.909, 3) - expect(zoomPercentOf(BASE_ZOOM_FACTOR)).toBe(100) - }) - - it('keeps the ladder landing exactly on Chromium native one step up', () => { - expect(steppedZoomFactor(BASE_ZOOM_FACTOR, 1)).toBe(1) - expect(zoomPercentOf(1)).toBe(110) - }) - - it('stays inside the ladder, so the page menu can still step both ways', () => { - expect(steppedZoomFactor(BASE_ZOOM_FACTOR, 1)).not.toBe(BASE_ZOOM_FACTOR) - expect(steppedZoomFactor(BASE_ZOOM_FACTOR, -1)).not.toBe(BASE_ZOOM_FACTOR) - }) }) describe('zoomPercentOf', () => { @@ -328,11 +143,4 @@ describe('zoomPercentOf', () => { expect(zoomPercentOf(BASE_ZOOM_FACTOR)).toBe(100) expect(zoomPercentOf(steppedZoomFactor(BASE_ZOOM_FACTOR, 1))).toBe(110) }) - - it('still reads 100% after a round trip up and back down', () => { - // The ladder is float arithmetic, so the reset item's `!== 100` guard has to - // survive a step that does not return bit-identically to the baseline. - const roundTripped = steppedZoomFactor(steppedZoomFactor(BASE_ZOOM_FACTOR, 1), -1) - expect(zoomPercentOf(roundTripped)).toBe(100) - }) }) diff --git a/apps/desktop/src/main/browser-agent/driver-profile.test.ts b/apps/desktop/src/main/browser-agent/driver-profile.test.ts index 90d5dab667d..6103f01de82 100644 --- a/apps/desktop/src/main/browser-agent/driver-profile.test.ts +++ b/apps/desktop/src/main/browser-agent/driver-profile.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -29,10 +29,6 @@ import { import type { ConfigStore } from '@/main/config' describe('clearBrowserProfile', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - it('requires settings erasure for sign-out but lets explicit server repair replace it', async () => { const config = { get: vi.fn(() => undefined), diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index fa402b3ef61..5baaed87f00 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1,6 +1,6 @@ import { BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS } from '@sim/browser-protocol' import { toRecord } from '@sim/utils/object' -import type { MenuItemConstructorOptions, WebContents } from 'electron' +import type { WebContents } from 'electron' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -15,11 +15,10 @@ vi.mock('@/main/browser-agent/file-transfer', () => ({ discardStagedUploads: vi.fn(async () => {}), })) -import { BrowserWindow, Menu, type nativeImage } from 'electron' +import { BrowserWindow, type nativeImage } from 'electron' import * as cdp from '@/main/browser-agent/cdp' import * as driverModule from '@/main/browser-agent/driver' import * as session from '@/main/browser-agent/session' -import { fillCoordinator } from '@/main/browser-credentials' import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store' type DriverModule = typeof import('@/main/browser-agent/driver') @@ -99,53 +98,6 @@ describe('executeTool', () => { expect(prepare).not.toHaveBeenCalled() }) - it('reports missing required parameters by name', async () => { - const result = await driver.executeTool('chat-test', 'browser_navigate', {}) - expect(result.ok).toBe(false) - expect(result.error).toMatch(/Missing required parameter "url"/) - }) - - it('loads SSRF-checked agent navigation destinations', async () => { - const navigations = [ - ['browser_navigate', 'http://127.0.0.1:4011/navigate'], - ['browser_open_url', 'http://127.0.0.1:4012/open'], - ['browser_open_tab', 'http://127.0.0.1:4013/tab'], - ] as const - - for (const [tool, url] of navigations) { - await expect(driver.executeTool('chat-test', tool, { url })).resolves.toMatchObject({ - ok: true, - }) - const contents = session.requireAutomationTab().view.webContents - expect(contents.loadURL).toHaveBeenCalledWith(url) - } - }) - - it('keeps the 400ms hydration grace without rediscovering a completed load', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const contents = session.requireTab().view.webContents - vi.useFakeTimers() - try { - let settled = false - const navigation = driver.executeTool('chat-test', 'browser_navigate', { - url: 'http://127.0.0.1/loaded', - }) - void navigation.then(() => { - settled = true - }) - await vi.advanceTimersByTimeAsync(0) - expect(contents.loadURL).toHaveBeenCalledWith('http://127.0.0.1/loaded') - - await vi.advanceTimersByTimeAsync(399) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - expect(settled).toBe(true) - await expect(navigation).resolves.toMatchObject({ ok: true }) - } finally { - vi.useRealTimers() - } - }) - it('still waits for a replacement load after loadURL has resolved', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) const contents = session.requireTab().view.webContents @@ -203,32 +155,6 @@ describe('executeTool', () => { } }) - it('accepts ERR_ABORTED only when a replacement navigation changed the URL', async () => { - vi.useFakeTimers() - try { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const contents = session.requireTab().view.webContents - let currentUrl = 'http://127.0.0.1/old' - vi.mocked(contents.getURL).mockImplementation(() => currentUrl) - vi.mocked(contents.loadURL).mockImplementation(async () => { - currentUrl = 'http://127.0.0.1/replacement' - throw Object.assign(new Error('net::ERR_ABORTED'), { code: 'ERR_ABORTED' }) - }) - - const navigation = driver.executeTool('chat-test', 'browser_navigate', { - url: 'http://127.0.0.1/new', - }) - await vi.advanceTimersByTimeAsync(1_000) - - await expect(navigation).resolves.toMatchObject({ - ok: true, - result: { url: 'http://127.0.0.1/replacement' }, - }) - } finally { - vi.useRealTimers() - } - }) - it('reports non-abort navigation failures instead of treating dispatch as success', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) const contents = session.requireTab().view.webContents @@ -246,40 +172,6 @@ describe('executeTool', () => { }) }) - it('serializes tool calls: a queued failure never rejects the next call', async () => { - const first = await driver.executeTool('chat-test', 'browser_snapshot', {}) - expect(first.ok).toBe(false) - const second = await driver.executeTool('chat-test', 'browser_list_tabs', {}) - // list_tabs works without a session (empty list). - expect(second.ok).toBe(true) - expect(second.result).toMatchObject({ tabs: [] }) - }) - - it('lists safe download metadata without opening a page', async () => { - const result = await driver.executeTool('chat-test', 'browser_list_downloads', {}) - - expect(result).toEqual({ - ok: true, - result: { scopeId: 'chat-test', downloads: [] }, - }) - expect(session.peekTabsState().tabs).toEqual([]) - }) - - it('reloads the active tab and waits for its load boundary', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const contents = session.requireTab().view.webContents - vi.useFakeTimers() - try { - const reload = driver.executeTool('chat-test', 'browser_reload', {}) - await vi.advanceTimersByTimeAsync(500) - - await expect(reload).resolves.toMatchObject({ ok: true }) - expect(contents.reload).toHaveBeenCalledOnce() - } finally { - vi.useRealTimers() - } - }) - it('uses the shared failed-page recovery path when reloading', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) const tab = session.requireTab() @@ -411,73 +303,6 @@ describe('executeTool', () => { } }) - it('does not let a detached takeover poll touch a disposed scope', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - vi.useFakeTimers() - const hasSession = vi.spyOn(session, 'hasSession') - try { - const takeover = driver.executeTool( - 'chat-test', - 'browser_request_takeover', - { reason: 'Please finish in the browser' }, - 'tool-disposed-takeover' - ) - await vi.advanceTimersByTimeAsync(0) - - driver.disposeBrowserScope('chat-test') - hasSession.mockClear() - await expect(takeover).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('cancelled'), - }) - await vi.advanceTimersByTimeAsync(1_500) - - expect(hasSession).not.toHaveBeenCalled() - } finally { - hasSession.mockRestore() - vi.useRealTimers() - } - }) - - it('does not let a detached text wait touch a disposed scope', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const contents = session.requireTab().view.webContents - vi.mocked(contents.getURL).mockReturnValue('https://example.com/') - let resolvePageProbe: (value: boolean) => void = () => {} - vi.mocked(contents.executeJavaScript).mockImplementation( - () => - new Promise((resolve) => { - resolvePageProbe = resolve - }) - ) - vi.useFakeTimers() - const automationTab = vi.spyOn(session, 'automationTab') - try { - const waiting = driver.executeTool( - 'chat-test', - 'browser_wait_for', - { text: 'ready', timeoutMs: 120_000 }, - 'tool-disposed-wait' - ) - await vi.advanceTimersByTimeAsync(0) - expect(contents.executeJavaScript).toHaveBeenCalled() - - driver.disposeBrowserScope('chat-test') - automationTab.mockClear() - await expect(waiting).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('cancelled'), - }) - resolvePageProbe(false) - await vi.advanceTimersByTimeAsync(300) - - expect(automationTab).not.toHaveBeenCalled() - } finally { - automationTab.mockRestore() - vi.useRealTimers() - } - }) - it('does not let a detached screenshot verification touch a disposed scope', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) const contents = session.requireTab().view.webContents @@ -581,50 +406,6 @@ describe('executeTool', () => { expect(session.withBrowserScope('chat-test', () => session.peekTabsState()).tabs).toEqual([]) }) - it('captures a missing scope without materializing driver state', async () => { - const boundary = driver.captureBrowserToolQueueBoundary('chat-not-yet-active') - expect(boundary).not.toBeNull() - if (!boundary) throw new Error('Expected browser tool authorization admission') - - expect(boundary).toMatchObject({ - scopeId: 'chat-not-yet-active', - generation: null, - cancellationEpoch: null, - }) - - driver.activateBrowserScope('chat-not-yet-active') - await expect( - driver.executeTool( - 'chat-not-yet-active', - 'browser_list_tabs', - {}, - 'tool-authorized-before-activation', - boundary - ) - ).resolves.toMatchObject({ ok: true }) - }) - - it('rejects a missing-scope authorization after process-wide browser teardown', async () => { - const boundary = driver.captureBrowserToolQueueBoundary('chat-not-yet-active') - expect(boundary).not.toBeNull() - if (!boundary) throw new Error('Expected browser tool authorization admission') - driver.closeBrowserSession() - driver.activateBrowserScope('chat-not-yet-active') - - await expect( - driver.executeTool( - 'chat-not-yet-active', - 'browser_list_tabs', - {}, - 'tool-authorized-before-global-close', - boundary - ) - ).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('cancelled before it started'), - }) - }) - it('rejects a first-use authorization after its scope is disposed and reopened', async () => { const boundary = driver.captureBrowserToolQueueBoundary('chat-first-use-disposed') expect(boundary).not.toBeNull() @@ -650,31 +431,6 @@ describe('executeTool', () => { ).toEqual([]) }) - it('rejects a first-use authorization after its scope is suspended and reopened', async () => { - const boundary = driver.captureBrowserToolQueueBoundary('chat-first-use-suspended') - expect(boundary).not.toBeNull() - if (!boundary) throw new Error('Expected browser tool authorization admission') - - expect(driver.suspendBrowserScope('chat-first-use-suspended')).toBe(true) - driver.activateBrowserScope('chat-first-use-suspended') - - await expect( - driver.executeTool( - 'chat-first-use-suspended', - 'browser_open_tab', - {}, - 'tool-authorized-before-first-use-suspension', - boundary - ) - ).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('cancelled before it started'), - }) - expect( - session.withBrowserScope('chat-first-use-suspended', () => session.peekTabsState()).tabs - ).toEqual([]) - }) - it('cancels a provisional first-use authorization when its durable scope is disposed', async () => { const boundary = driver.captureBrowserToolQueueBoundary('pending:first-use-disposed') expect(boundary).not.toBeNull() @@ -719,28 +475,6 @@ describe('executeTool', () => { ).resolves.toMatchObject({ ok: true }) }) - it('rejects an existing-scope authorization after disposal and recreation', async () => { - const boundary = driver.captureBrowserToolQueueBoundary('chat-test') - expect(boundary).not.toBeNull() - if (!boundary) throw new Error('Expected browser tool authorization admission') - - driver.disposeBrowserScope('chat-test') - driver.activateBrowserScope('chat-test') - - await expect( - driver.executeTool( - 'chat-test', - 'browser_list_tabs', - {}, - 'tool-authorized-before-scope-disposal', - boundary - ) - ).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('cancelled before it started'), - }) - }) - it('bounds pending authorizations without materializing their scopes', () => { const boundaries = capturePendingAuthorizations(driver, 'chat-pending-authorization') @@ -766,32 +500,6 @@ describe('executeTool', () => { if (replacement) driver.releaseBrowserToolQueueBoundary(replacement) }) - it('retains disposed-scope authorization admissions until their fetches settle', () => { - const boundaries = capturePendingAuthorizations(driver, 'chat-disposed-authorizations') - - driver.disposeBrowserScope('chat-disposed-authorizations') - expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true) - expect(driver.captureBrowserToolQueueBoundary('chat-disposed-authorizations')).toBeNull() - - releasePendingAuthorizations(driver, boundaries) - const replacement = driver.captureBrowserToolQueueBoundary('chat-disposed-authorizations') - expect(replacement).not.toBeNull() - if (replacement) driver.releaseBrowserToolQueueBoundary(replacement) - }) - - it('retains suspended-scope authorization admissions until their fetches settle', () => { - const boundaries = capturePendingAuthorizations(driver, 'chat-suspended-authorizations') - - expect(driver.suspendBrowserScope('chat-suspended-authorizations')).toBe(true) - expect(boundaries.every((boundary) => boundary.cancelled)).toBe(true) - expect(driver.captureBrowserToolQueueBoundary('chat-suspended-authorizations')).toBeNull() - - releasePendingAuthorizations(driver, boundaries) - const replacement = driver.captureBrowserToolQueueBoundary('chat-suspended-authorizations') - expect(replacement).not.toBeNull() - if (replacement) driver.releaseBrowserToolQueueBoundary(replacement) - }) - it('retains process-wide authorization admissions across driver reinitialization', () => { const boundaries = ['chat-auth-a', 'chat-auth-b', 'chat-auth-c', 'chat-auth-d'].flatMap( (scopeId) => capturePendingAuthorizations(driver, scopeId) @@ -829,31 +537,6 @@ describe('executeTool', () => { }) }) - it('settles native automation activity immediately when an active tool is cancelled', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - vi.useFakeTimers() - try { - const waiting = driver.executeTool( - 'chat-test', - 'browser_wait_for', - { timeoutMs: 120_000 }, - 'tool-waiting' - ) - await vi.advanceTimersByTimeAsync(0) - expect(session.getTabsState().automationActive).toBe(true) - - expect(driver.cancelActiveTool('chat-test')).toBe(true) - await vi.advanceTimersByTimeAsync(0) - expect(session.getTabsState().automationActive).toBe(false) - await expect(waiting).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('cancelled'), - }) - } finally { - vi.useRealTimers() - } - }) - it('cancels queued pre-boundary tools while allowing later browser work', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) vi.useFakeTimers() @@ -913,33 +596,6 @@ describe('executeTool', () => { } }) - it('returns a free-text takeover instruction to the browser agent', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - vi.useFakeTimers() - try { - const takeover = driver.executeTool('chat-test', 'browser_request_takeover', { - reason: 'Please pick a match in the draw', - }) - await vi.advanceTimersByTimeAsync(0) - - await driver.handlePanelAction('chat-test', { - action: 'takeover-done', - takeoverResponse: 'Open the second match', - }) - await vi.advanceTimersByTimeAsync(1_500) - - await expect(takeover).resolves.toMatchObject({ - ok: true, - result: { - completed: true, - userInstruction: 'Open the second match', - }, - }) - } finally { - vi.useRealTimers() - } - }) - it('publishes a settled tab when the main frame finishes before subresources', async () => { const onPageState = vi.fn() const onTabsState = vi.fn() @@ -1033,22 +689,6 @@ describe('executeTool', () => { expect(contents.loadURL).toHaveBeenCalledWith(failedUrl) }) - it('forces fill availability to replay on scope activation and tab switches', async () => { - const refreshAvailability = vi - .spyOn(fillCoordinator()!, 'refreshAvailability') - .mockResolvedValue() - - driver.activateBrowserScope('chat-with-login') - expect(refreshAvailability).toHaveBeenCalledWith(true) - - await driver.executeTool('chat-with-login', 'browser_open_tab', {}) - await driver.executeTool('chat-with-login', 'browser_open_tab', {}) - refreshAvailability.mockClear() - session.switchTab('1') - - expect(refreshAvailability).toHaveBeenCalledWith(true) - }) - it('keeps target-blank initiation user-owned while automation is active', async () => { driver = freshDriver() await driver.executeTool('chat-test', 'browser_open_tab', {}) @@ -1090,142 +730,7 @@ describe('executeTool', () => { expect(popup.loadURL).toHaveBeenCalledWith('https://agent-popup.example/') }) - it('keeps context-menu new tabs user-owned while automation is active', async () => { - driver = freshDriver() - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const source = session.requireTab().view.webContents - session.setAutomationActive(true) - vi.mocked(Menu.buildFromTemplate).mockClear() - const contextMenu = (source.on as unknown as ReturnType).mock.calls.find( - ([eventName]) => eventName === 'context-menu' - )?.[1] as (event: unknown, params: unknown) => void - contextMenu( - {}, - { - selectionText: '', - linkURL: 'https://context-link.example/', - isEditable: false, - editFlags: { canPaste: false }, - } - ) - const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as - | MenuItemConstructorOptions[] - | undefined - template - ?.find((item) => item.label === 'Open Link in New Tab') - ?.click?.({} as never, undefined as never, {} as never) - - const popup = session.activeTab()?.view.webContents - if (!popup) throw new Error('Expected context-menu tab') - expect(session.automationTab()?.view.webContents).toBe(source) - expect(popup.loadURL).toHaveBeenCalledWith('https://context-link.example/') - }) - - it('builds the native toolbar menu and routes renderer-owned actions back to its chat', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const win = new BrowserWindow() - vi.mocked(Menu.buildFromTemplate).mockClear() - - expect(driver.showToolbarMenu('chat-test', win, { x: 20, y: 30 })).toBe(true) - const template = vi.mocked(Menu.buildFromTemplate).mock.calls[0]?.[0] as - | MenuItemConstructorOptions[] - | undefined - const labels = template?.filter((item) => item.type !== 'separator').map((item) => item.label) - expect(labels).toEqual([ - 'Find in Page', - 'Zoom (110%)', - 'Fill Saved Password', - 'Passwords', - 'Import Passwords', - 'Browser Settings', - ]) - - const settings = template?.find((item) => item.label === 'Browser Settings') - const openSettings = settings?.click as (() => void) | undefined - openSettings?.() - expect(win.webContents.send).toHaveBeenCalledWith( - 'browser-agent:toolbar-command', - 'browser-settings', - 'chat-test' - ) - }) - - it('routes an exact renderer media decision through the scoped session boundary', async () => { - const respond = vi.spyOn(session, 'respondToMediaPermission').mockResolvedValue() - - await driver.handlePanelAction('chat-test', { - action: 'respond-media-permission', - requestId: 'request-1', - allowed: true, - }) - await driver.handlePanelAction('chat-test', { - action: 'respond-media-permission', - requestId: 'request-2', - }) - - expect(respond).toHaveBeenCalledOnce() - expect(respond).toHaveBeenCalledWith('request-1', true) - }) - - it('ignores retired site decisions without changing tab ownership', async () => { - const claim = vi.spyOn(session, 'claimActiveTabForUser') - - await driver.handlePanelAction('chat-test', { - action: 'respond-site-permission', - requestId: 'request-1', - allowed: true, - }) - await driver.handlePanelAction('chat-test', { - action: 'respond-site-permission', - requestId: 'request-2', - }) - - expect(claim).not.toHaveBeenCalled() - }) - - it('loads the exact URL entered through the user omnibox', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const contents = session.requireTab().view.webContents - - await driver.handlePanelAction('chat-test', { - action: 'navigate', - url: 'https://docs.example/private?token=secret', - }) - - expect(contents.loadURL).toHaveBeenCalledWith('https://docs.example/private?token=secret') - }) - - it('waits for a selected restored tab before reporting it ready to the model', async () => { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const tab = session.requireTab() - let releaseRestore = () => {} - const wait = vi.spyOn(session, 'waitForPendingTabRestore').mockImplementation( - () => - new Promise((resolve) => { - releaseRestore = () => resolve(true) - }) - ) - let settled = false - const switched = driver - .executeTool('chat-test', 'browser_switch_tab', { tabId: tab.id }) - .then((result) => { - settled = true - return result - }) - - await Promise.resolve() - expect(settled).toBe(false) - expect(wait).toHaveBeenCalledWith(tab) - - releaseRestore() - await expect(switched).resolves.toMatchObject({ - ok: true, - result: { tabId: tab.id }, - }) - wait.mockRestore() - }) - - it('does not report a timed-out restored tab as ready to the model', async () => { + it('does not report a timed-out restored tab as ready to the model', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) const tab = session.requireTab() const wait = vi.spyOn(session, 'waitForPendingTabRestore').mockResolvedValue(false) @@ -1240,33 +745,6 @@ describe('executeTool', () => { wait.mockRestore() }) - it('allows a fifty-second restored-tab consent and load without duplicating the tab', async () => { - vi.useFakeTimers() - try { - await driver.executeTool('chat-test', 'browser_open_tab', {}) - const tab = session.requireTab() - const wait = vi.spyOn(session, 'waitForPendingTabRestore').mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => resolve(true), 50_000) - }) - ) - - const switched = driver.executeTool('chat-test', 'browser_switch_tab', { tabId: tab.id }) - await vi.advanceTimersByTimeAsync(50_000) - - await expect(switched).resolves.toMatchObject({ - ok: true, - result: { tabId: tab.id }, - }) - expect(session.listTabs()).toHaveLength(1) - expect(session.requireTab()).toBe(tab) - wait.mockRestore() - } finally { - vi.useRealTimers() - } - }) - it('keeps tool queues and tab state isolated by chat scope', async () => { await driver.executeTool('chat-a', 'browser_open_tab', {}) await driver.executeTool('chat-a', 'browser_open_tab', {}) @@ -1300,18 +778,6 @@ describe('executeTool', () => { expect(driver.migrateBrowserScope('pending:other-chat', 'chat-occupied')).toBe(false) }) - it('keeps a durable destination adoptable after an empty restore', async () => { - await driver.executeTool('pending:new-chat', 'browser_open_tab', {}) - driver.activateBrowserScope('chat-real') - expect(driver.restoreBrowserScope('chat-real')).toMatchObject({ tabs: [] }) - - expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true) - await expect(driver.executeTool('chat-real', 'browser_list_tabs', {})).resolves.toMatchObject({ - ok: true, - result: { scopeId: 'chat-real', tabs: [{ tabId: '1' }] }, - }) - }) - it('cancels only the replaced destination authorizations during migration', async () => { await driver.executeTool('pending:new-chat', 'browser_open_tab', {}) driver.activateBrowserScope('chat-real') @@ -1359,57 +825,6 @@ describe('executeTool', () => { ).resolves.toMatchObject({ ok: true }) }) - it('retains replaced destination admissions until their authorization fetches settle', async () => { - await driver.executeTool('pending:new-chat', 'browser_open_tab', {}) - driver.activateBrowserScope('chat-real') - const sourceBoundary = driver.captureBrowserToolQueueBoundary('pending:new-chat') - const destinationBoundaries = capturePendingAuthorizations( - driver, - 'chat-real', - driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope - 1 - ) - expect(sourceBoundary).not.toBeNull() - if (!sourceBoundary) throw new Error('Expected source authorization admission') - - expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true) - - expect(destinationBoundaries.every((boundary) => boundary.cancelled)).toBe(true) - expect(sourceBoundary.cancelled).toBe(false) - expect(driver.captureBrowserToolQueueBoundary('chat-real')).toBeNull() - - releasePendingAuthorizations(driver, destinationBoundaries) - await expect( - driver.executeTool( - 'chat-real', - 'browser_list_tabs', - {}, - 'tool-source-after-destination-settlement', - sourceBoundary - ) - ).resolves.toMatchObject({ ok: true }) - const replacement = driver.captureBrowserToolQueueBoundary('chat-real') - expect(replacement).not.toBeNull() - if (replacement) driver.releaseBrowserToolQueueBoundary(replacement) - }) - - it('keeps migrated source admissions charged to the durable scope after disposal', () => { - driver.activateBrowserScope('pending:new-chat') - const sourceBoundaries = capturePendingAuthorizations(driver, 'pending:new-chat') - - expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true) - expect(sourceBoundaries.every((boundary) => boundary.scopeId === 'chat-real')).toBe(true) - - driver.disposeBrowserScope('chat-real') - driver.activateBrowserScope('chat-real') - expect(sourceBoundaries.every((boundary) => boundary.cancelled)).toBe(true) - expect(driver.captureBrowserToolQueueBoundary('chat-real')).toBeNull() - - releasePendingAuthorizations(driver, sourceBoundaries) - const replacement = driver.captureBrowserToolQueueBoundary('chat-real') - expect(replacement).not.toBeNull() - if (replacement) driver.releaseBrowserToolQueueBoundary(replacement) - }) - it('retains a migrated provisional alias for callbacks until durable disposal', async () => { await driver.executeTool('pending:new-chat', 'browser_open_tab', {}) const tab = session.withBrowserScope('pending:new-chat', () => session.requireTab()) @@ -1639,61 +1054,6 @@ describe('executeTool', () => { } }) - it('bounds process-wide queues across scopes and recovers capacity on disposal', async () => { - vi.useFakeTimers() - const scopes = Array.from( - { - length: - driver.BROWSER_TOOL_ADMISSION_LIMITS.process / - driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope, - }, - (_, index) => `chat-admission-${index}` - ) - const executions: Array> = [] - try { - for (const scopeId of scopes) { - await driver.executeTool(scopeId, 'browser_open_tab', {}) - const contents = session.withBrowserScope( - scopeId, - () => session.requireTab().view.webContents - ) - vi.mocked(contents.getURL).mockReturnValue('https://example.com/') - vi.mocked(contents.executeJavaScript).mockImplementation(() => new Promise(() => {})) - executions.push( - driver.executeTool(scopeId, 'browser_snapshot', {}, `held-process-${scopeId}`) - ) - await vi.advanceTimersByTimeAsync(0) - for (let index = 1; index < driver.BROWSER_TOOL_ADMISSION_LIMITS.perScope; index++) { - executions.push( - driver.executeTool( - scopeId, - 'browser_list_tabs', - {}, - `queued-process-${scopeId}-${index}` - ) - ) - } - } - - await expect( - driver.executeTool('chat-process-overflow', 'browser_list_tabs', {}, 'process-overflow') - ).resolves.toEqual({ - ok: false, - error: - 'Sim already has too many browser actions queued. Wait for earlier actions to finish.', - }) - - driver.disposeBrowserScope(scopes[0]) - await expect( - driver.executeTool('chat-process-recovered', 'browser_list_tabs', {}, 'process-recovered') - ).resolves.toMatchObject({ ok: true }) - } finally { - for (const scopeId of scopes) driver.disposeBrowserScope(scopeId) - await Promise.allSettled(executions) - vi.useRealTimers() - } - }) - it('sanitizes hostile tab titles before returning them across the tool boundary', async () => { await driver.executeTool('chat-test', 'browser_open_tab', {}) const contents = session.requireTab().view.webContents @@ -2210,22 +1570,6 @@ describe('credential protection', () => { } ) - it.each([ - { value: 'a', values: ['b'] }, - { values: [1] }, - { values: Array.from({ length: 101 }, () => 'a') }, - {}, - ])('rejects invalid selection arguments before dispatch', async (params) => { - const contents = await openPage() - vi.mocked(contents.executeJavaScript).mockClear() - const result = await driver.executeTool('chat-test', 'browser_select_option', { - elementId: 0, - ...params, - }) - expect(result.ok).toBe(false) - expect(contents.executeJavaScript).not.toHaveBeenCalled() - }) - const formFields = [ { elementId: 1, kind: 'select', value: 'first' }, { elementId: 2, kind: 'select', value: 'second' }, @@ -2251,22 +1595,6 @@ describe('credential protection', () => { }) }) - it.each([ - { fields: [] }, - { - fields: Array.from({ length: 9 }, (_, elementId) => ({ elementId, kind: 'text', text: 'x' })), - }, - { fields: [formFields[0], { ...formFields[1], submit: true }] }, - { fields: [formFields[0], formFields[0]] }, - { fields: [{ elementId: 0, kind: 'text', text: 'x'.repeat(4097) }] }, - ])('validates the entire bounded form payload before writing', async (params) => { - const form = await openForm() - expect(await driver.executeTool('chat-test', 'browser_fill_form', params)).toMatchObject({ - ok: false, - }) - expect(form.writes).toEqual([]) - }) - it('preflights later secret fields before changing earlier fields', async () => { const form = await openForm({ refuseAt: 1 }) const result = await driver.executeTool('chat-test', 'browser_fill_form', { @@ -2316,17 +1644,6 @@ describe('credential protection', () => { }) }) - it('stops before the next field after same-document navigation', async () => { - const form: Awaited> = await openForm({ - afterWrite: () => vi.mocked(form.contents.getURL).mockReturnValue('https://example.com/next'), - }) - const result = await driver.executeTool('chat-test', 'browser_fill_form', { - fields: formFields, - }) - expect(form.writes).toEqual([0]) - expect(result).toMatchObject({ ok: true, result: { completed: false, doNotRetry: true } }) - }) - it('detects a later field changing an earlier completed field', async () => { const form: Awaited> = await openForm({ afterWrite: (index) => { @@ -2426,34 +1743,6 @@ describe('credential protection', () => { expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0) }) - it('repeats a keystroke with a trusted down/up pair per press', async () => { - const contents = await openPage() - respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} }) - - const result = await driver.executeTool('chat-test', 'browser_press_key', { - key: 'ArrowRight', - repeat: 3, - }) - - expect(result).toMatchObject({ ok: true, result: { pressed: 'ArrowRight', repeat: 3 } }) - const downs = cdpCalls(contents, 'Input.dispatchKeyEvent').filter( - ([, event]) => (event as { type?: string }).type === 'rawKeyDown' - ) - expect(downs).toHaveLength(3) - }) - - it('rejects an out-of-range repeat before dispatch', async () => { - const contents = await openPage() - - const result = await driver.executeTool('chat-test', 'browser_press_key', { - key: 'Tab', - repeat: 51, - }) - - expect(result).toMatchObject({ ok: false, error: expect.stringContaining('repeat must') }) - expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0) - }) - it('stops repeating once focus reaches a password field', async () => { const contents = await openPage() let secrecyChecks = 0 @@ -2476,16 +1765,6 @@ describe('credential protection', () => { expect(downs).toHaveLength(1) }) - it('sends the keystroke when nothing sensitive is focused', async () => { - const contents = await openPage() - respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} }) - - const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'a' }) - - expect(result.ok).toBe(true) - expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0) - }) - it.each(['cancelled', 'timed out'] as const)( 'retains a completed action when its observation is %s', async (stop) => { @@ -2555,38 +1834,6 @@ describe('credential protection', () => { } ) - it('reports when a platform-mismatched shortcut produces no observable effect', async () => { - const contents = await openPage() - respondWith(contents, { - activeElementSecrecy: 'safe', - readActiveElementState: { - activeElement: 'body', - selectedChars: 0, - valueLength: 0, - valuePreview: '', - }, - readPageActionState: { - url: 'https://example.com/login', - title: 'Example', - focus: 'body', - mutationRevision: 0, - dialogs: [], - scroll: [0], - }, - }) - - const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'Control+K' }) - - expect(result).toMatchObject({ - ok: true, - result: { - pressed: 'Control+K', - effectObserved: false, - note: expect.stringContaining('No strong observable page change'), - }, - }) - }) - it('aborts a type when focus moves to a password field before the insert', async () => { const contents = await openPage() let focusReads = 0 @@ -2652,29 +1899,6 @@ describe('credential protection', () => { expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) }) - it('warns when acknowledged text produces no observable field change', async () => { - const contents = await openPage() - respondWith(contents, { - focusElementForTyping: { focused: true, kind: 'input', x: 24, y: 48 }, - activeElementSecrecy: 'safe', - readActiveElementState: { activeElement: 'input', valueLength: 7 }, - }) - - const result = await driver.executeTool('chat-test', 'browser_type', { - elementId: 0, - text: 'hunter2', - }) - - expect(result.ok).toBe(true) - expect(result).toMatchObject({ - result: { - effectObserved: false, - note: expect.stringContaining('field readback did not change'), - }, - }) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) - }) - it('sets structured input values without dispatching text or select-all keystrokes', async () => { const contents = await openPage() respondWith(contents, { @@ -2713,130 +1937,12 @@ describe('credential protection', () => { expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) }) - it('reports an interrupted structured write as uncertain without replaying it', async () => { + it('confirms typing only after the field readback changes', async () => { const contents = await openPage() - let writes = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'focusElementForTyping')) - return Promise.resolve({ focused: true, valueInput: true, x: 24, y: 48 }) - if (isPageCall(expression, 'setFocusedInputValue')) { - writes++ - return Promise.reject(new Error('Execution context was destroyed')) - } - return Promise.resolve({}) - }) - const result = await driver.executeTool('chat-test', 'browser_type', { - elementId: 0, - text: '2026-09-15', - }) - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining('may have reached the field and was not retried'), - }) - expect(writes).toBe(1) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) - expect( - vi - .mocked(contents.executeJavaScript) - .mock.calls.some(([expression]) => isPageCall(String(expression), 'typeIntoElement')) - ).toBe(false) - }) - - it('refuses a field whose input mode changes before dispatch', async () => { - const contents = await openPage() - let reads = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'focusElementForTyping')) - return Promise.resolve({ focused: true, valueInput: ++reads === 1, x: 24, y: 48 }) - return Promise.resolve({}) - }) - const result = await driver.executeTool('chat-test', 'browser_type', { - elementId: 0, - text: '2026-09-15', - }) - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining('field type changed'), - }) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) - expect( - vi - .mocked(contents.executeJavaScript) - .mock.calls.some(([expression]) => isPageCall(String(expression), 'setFocusedInputValue')) - ).toBe(false) - }) - - it('accepts empty text and sends it through native insertion to clear a field', async () => { - const contents = await openPage() - respondWith(contents, { - focusElementForTyping: { focused: true, kind: 'input', x: 24, y: 48 }, - readActiveElementState: { activeElement: 'input', valueLength: 0, valuePreview: '' }, - readPageActionState: {}, - }) - - const result = await driver.executeTool('chat-test', 'browser_type', { elementId: 0, text: '' }) - - expect(result).toMatchObject({ ok: true, result: { dispatched: true, trusted: true } }) - expect(cdpCalls(contents, 'Input.insertText')).toEqual([['Input.insertText', { text: '' }]]) - }) - - it.each([{}, { text: undefined }, { text: null }, { text: 7 }, { text: false }])( - 'rejects missing or nonstring text before native input', - async (params) => { - const contents = await openPage() - const result = await driver.executeTool('chat-test', 'browser_type', { - elementId: 0, - ...params, - }) - - expect(result).toMatchObject({ ok: false, error: expect.stringContaining('text') }) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) - expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0) - } - ) - - it('types through a focused combobox suggestions popup without pointer probing', async () => { - const contents = await openPage() - respondWith(contents, { - focusElementForTyping: { - focused: true, - kind: 'input', - x: 24, - y: 48, - coveredByRelatedPopup: true, - }, - readActiveElementState: { activeElement: 'input', valueLength: 0 }, - readPageActionState: { - url: 'https://example.com/login', - title: 'Compose', - focus: 'input:combobox:::To:', - mutationRevision: 0, - dialogs: [], - popups: ['Contact list'], - scroll: [0], - }, - }) - - const result = await driver.executeTool('chat-test', 'browser_type', { - elementId: 0, - text: 'Mondu', - }) - - expect(result).toMatchObject({ ok: true, result: { dispatched: true, trusted: true } }) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) - expect( - vi - .mocked(contents.executeJavaScript) - .mock.calls.some(([expression]) => isPageCall(String(expression), 'clickElement')) - ).toBe(false) - }) - - it('confirms typing only after the field readback changes', async () => { - const contents = await openPage() - let inserted = false - const observedInsertionStates: boolean[] = [] - vi.mocked(contents.debugger.sendCommand).mockImplementation((method) => { - if (method === 'Input.insertText') inserted = true + let inserted = false + const observedInsertionStates: boolean[] = [] + vi.mocked(contents.debugger.sendCommand).mockImplementation((method) => { + if (method === 'Input.insertText') inserted = true return Promise.resolve({}) }) vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { @@ -2893,41 +1999,6 @@ describe('credential protection', () => { } ) - it('still allows select-all, which carries no clipboard content', async () => { - const contents = await openPage() - respondWith(contents, { activeElementSecrecy: 'safe', readActiveElementState: {} }) - - const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'Cmd+A' }) - - expect(result.ok).toBe(true) - }) - - it('surfaces the page-side refusal for element-targeted actions', async () => { - const contents = await openPage() - respondWith(contents, { clickElement: { error: 'password' } }) - - const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - - expect(result.ok).toBe(false) - expect(result.error).toMatch(/Refusing to act on a password field/) - }) - - it('guides typing through owned suggestions without dispatching a pointer click', async () => { - const contents = await openPage() - respondWith(contents, { - clickElement: { error: 'suggestions-open', blocker: 'Contact list' }, - }) - - const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining('Use browser_type on the same element'), - }) - expect(result.error).toContain('do not dismiss the popup') - expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) - }) - it('uses trusted CDP mouse input for element clicks', async () => { const contents = await openPage() respondWith(contents, { @@ -2957,41 +2028,6 @@ describe('credential protection', () => { expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(3) }) - it('returns the actual inner-container movement from browser_scroll', async () => { - const contents = await openPage() - respondWith(contents, { - scrollPage: { - direction: 'up', - requestedAmount: 500, - target: 'Message history', - targetSource: 'viewport-center', - movedBy: -500, - scrollTop: 1_000, - scrollHeight: 4_000, - clientHeight: 800, - atTop: false, - atBottom: false, - }, - }) - - const result = await driver.executeTool('chat-test', 'browser_scroll', { - direction: 'up', - amount: 500, - }) - - expect(result).toMatchObject({ - ok: true, - result: { - target: 'Message history', - targetSource: 'viewport-center', - movedBy: -500, - scrollTop: 1_000, - atTop: false, - atBottom: false, - }, - }) - }) - it('rejects an unsupported browser_scroll direction instead of treating it as down', async () => { const contents = await openPage() @@ -3010,35 +2046,6 @@ describe('credential protection', () => { ).toBe(false) }) - it.each(['left', 'right'])( - 'accepts browser_scroll %s and returns horizontal movement', - async (direction) => { - const contents = await openPage() - const movedBy = direction === 'left' ? -100 : 100 - respondWith(contents, { - scrollPage: { - target: 'Table columns', - targetSource: 'viewport-center', - movedBy, - scrollLeft: 300, - scrollWidth: 1_000, - clientWidth: 200, - atLeft: false, - atRight: false, - atTop: true, - atBottom: true, - }, - }) - - expect( - await driver.executeTool('chat-test', 'browser_scroll', { direction, amount: 100 }) - ).toMatchObject({ - ok: true, - result: { movedBy, scrollLeft: 300, atLeft: false, atRight: false }, - }) - } - ) - it('confirms a click when the requested target changes semantic state', async () => { const contents = await openPage() let actionReads = 0 @@ -3070,48 +2077,6 @@ describe('credential protection', () => { }) }) - it('confirms a panel close when the clicked target semantically disappears', async () => { - const contents = await openPage() - let actionReads = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'clickElement')) { - return Promise.resolve({ dispatched: false, x: 24, y: 48, element: 'Close thread' }) - } - if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({}) - if (isPageCall(expression, 'readPageActionState')) { - actionReads++ - return Promise.resolve({ - url: 'https://example.com/thread', - title: 'Thread', - focus: 'body', - mutationRevision: actionReads === 1 ? 0 : 2, - dialogs: [], - popups: [], - scroll: [0], - targetState: - actionReads === 1 - ? { present: true, rendered: true } - : { present: false, rendered: false }, - }) - } - return Promise.resolve(undefined) - }) - - const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - - expect(result).toMatchObject({ - ok: true, - result: { - effectObserved: true, - possibleEffectObserved: true, - effect: { targetChanged: true }, - }, - }) - expect(result).not.toMatchObject({ - result: { note: expect.stringContaining('background DOM/title churn') }, - }) - }) - it('reports failed submit dispatch separately from a completed text write', async () => { const contents = await openPage() respondWith(contents, { @@ -3237,43 +2202,6 @@ describe('credential protection', () => { expect(mainFrame.executeJavaScript).not.toHaveBeenCalled() }) - // A dialog that was ALREADY open before the click is not obstructing the - // navigation it survived — reporting it made every SPA route change under a - // persistent role=dialog (cookie banner, side drawer, picker) read as a - // failed click. Only a dialog that arrives with the navigation obstructs it. - // targetChanged can only fire when pageActionState was given an elementId. - // Tools without one listed it in their effect formula for a long time, where - // it was silently always false — coverage that read as real. This pins the - // dependency so the next tool that adds the term has to earn it. - it('cannot observe a target change for a tool that passes no elementId', async () => { - const contents = await openPage() - let actionReads = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({}) - if (isPageCall(expression, 'readPageActionState')) { - actionReads++ - // No targetState in either sample: that is what a call without an - // elementId returns. - return Promise.resolve({ - url: 'https://example.com/a', - title: 'A', - focus: 'body', - mutationRevision: actionReads === 1 ? 0 : 3, - dialogs: [], - popups: [], - scroll: [0], - }) - } - return Promise.resolve(undefined) - }) - - const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'Enter' }) - - expect(result).toMatchObject({ ok: true }) - const effect = (result as { result?: { effect?: Record } }).result?.effect - expect(effect?.targetChanged).toBe(false) - }) - // The click-that-navigates race from the field: "Begin Assessment" submits a // form, the navigation tears the origin document down, and the CDP dispatch // rejects mid-press. The press already reached the page — the navigation IS @@ -3320,47 +2248,6 @@ describe('credential protection', () => { }) }) - it('ignores a dialog that was already open before the click', async () => { - const contents = await openPage() - let actionReads = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'clickElement')) { - return Promise.resolve({ dispatched: false, x: 24, y: 48, element: 'Search result' }) - } - if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({}) - if (isPageCall(expression, 'readPageActionState')) { - actionReads++ - return Promise.resolve( - actionReads === 1 - ? { - url: 'https://example.com/search', - title: 'Search', - focus: 'body', - mutationRevision: 0, - dialogs: ['Search'], - scroll: [0], - } - : { - url: 'https://example.com/channel/eng-bugs', - title: 'eng-bugs', - focus: 'body', - mutationRevision: 1, - dialogs: ['Search'], - scroll: [0], - } - ) - } - return Promise.resolve(undefined) - }) - - const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - - expect(result).toMatchObject({ - ok: true, - result: { effectObserved: true, obstructedAfterNavigation: false, dialogs: ['Search'] }, - }) - }) - it('reports navigation obstructed by a dialog that opened with it', async () => { const contents = await openPage() let actionReads = 0 @@ -3405,58 +2292,6 @@ describe('credential protection', () => { }) }) - it('surfaces a CDP dialog notice on the next tool result exactly once', async () => { - const contents = await openPage() - const listener = vi - .mocked(contents.debugger.on) - .mock.calls.find(([event]) => event === 'message')?.[1] as - | ((event: unknown, method: string, params: unknown, sessionId?: string) => void) - | undefined - expect(listener).toBeTypeOf('function') - - listener?.({}, 'Page.javascriptDialogOpening', { type: 'alert', message: 'Heads up' }) - await vi.waitFor(() => - expect(contents.debugger.sendCommand).toHaveBeenCalledWith('Page.handleJavaScriptDialog', { - accept: false, - }) - ) - - const first = await driver.executeTool('chat-test', 'browser_list_tabs', {}) - const second = await driver.executeTool('chat-test', 'browser_list_tabs', {}) - - expect(first).toMatchObject({ - ok: true, - result: { - notices: [expect.stringContaining('alert dialog ("Heads up") which was dismissed')], - }, - }) - expect(second).not.toMatchObject({ result: { notices: expect.anything() } }) - }) - - it('runs batched actions in order and returns each result', async () => { - const contents = await openPage() - respondWith(contents, {}) - - const result = await driver.executeTool('chat-test', 'browser_batch', { - actions: [ - { tool: 'browser_click', args: { elementId: 0 } }, - { tool: 'browser_click', args: { elementId: 0 } }, - ], - }) - - expect(result).toMatchObject({ - ok: true, - result: { - completed: true, - completedCount: 2, - results: [ - { index: 0, tool: 'browser_click', result: { dispatched: true } }, - { index: 1, tool: 'browser_click', result: { dispatched: true } }, - ], - }, - }) - }) - it('stops a batch at the first failed action and keeps earlier results', async () => { const contents = await openPage() vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { @@ -3513,44 +2348,16 @@ describe('credential protection', () => { }) }) - it('stops a batch after an action changes the URL within the document', async () => { + it('reports a batch cancelled during its first action as an unknown outcome', async () => { const contents = await openPage() respondWith(contents, {}) - const url = contents.getURL() const sendCommand = vi.mocked(contents.debugger.sendCommand) const dispatch = sendCommand.getMockImplementation() - sendCommand.mockImplementation((method, params) => { - if (method === 'Input.dispatchMouseEvent' && toRecord(params).type === 'mouseReleased') { - vi.mocked(contents.getURL).mockReturnValue(`${url}#next`) - emitContentsEvent(contents, 'did-navigate-in-page') - } - return dispatch?.(method, params) ?? Promise.resolve(undefined) - }) - - const result = await driver.executeTool('chat-test', 'browser_batch', { - actions: [ - { tool: 'browser_click', args: { elementId: 0 } }, - { tool: 'browser_click', args: { elementId: 0 } }, - ], - }) - - expect(mousePresses(contents)).toBe(1) - expect(result).toMatchObject({ - ok: true, - result: { completed: false, completedCount: 1, stoppedIndex: 1, stoppedBy: 'page-change' }, - }) - }) - - it('reports a batch cancelled during its first action as an unknown outcome', async () => { - const contents = await openPage() - respondWith(contents, {}) - const sendCommand = vi.mocked(contents.debugger.sendCommand) - const dispatch = sendCommand.getMockImplementation() - sendCommand.mockImplementation((method, params) => - method === 'Input.dispatchKeyEvent' - ? new Promise(() => {}) - : (dispatch?.(method, params) ?? Promise.resolve(undefined)) - ) + sendCommand.mockImplementation((method, params) => + method === 'Input.dispatchKeyEvent' + ? new Promise(() => {}) + : (dispatch?.(method, params) ?? Promise.resolve(undefined)) + ) const pending = driver.executeTool( 'chat-test', @@ -3572,65 +2379,6 @@ describe('credential protection', () => { }) }) - it('reports a batch cancelled after an action ran as an unknown outcome', async () => { - const contents = await openPage() - respondWith(contents, {}) - const sendCommand = vi.mocked(contents.debugger.sendCommand) - const dispatch = sendCommand.getMockImplementation() - sendCommand.mockImplementation((method, params) => - method === 'Input.dispatchKeyEvent' - ? new Promise(() => {}) - : (dispatch?.(method, params) ?? Promise.resolve(undefined)) - ) - - const pending = driver.executeTool( - 'chat-test', - 'browser_batch', - { - actions: [ - { tool: 'browser_click', args: { elementId: 0 } }, - { tool: 'browser_press_key', args: { key: 'Enter' } }, - ], - }, - 'batch-call' - ) - await vi.waitFor(() => expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(1)) - driver.cancelTool('chat-test', 'batch-call') - - await expect(pending).resolves.toMatchObject({ - ok: true, - result: { outcomeUnknown: true, doNotRetry: true }, - }) - }) - - it('reports a press-and-hold cancelled mid-hold as an unknown outcome', async () => { - const contents = await openPage() - respondWith(contents, {}) - - const pending = driver.executeTool( - 'chat-test', - 'browser_click', - { elementId: 0, holdMs: 5_000 }, - 'hold-call' - ) - await vi.waitFor(() => - expect( - cdpCalls(contents, 'Input.dispatchMouseEvent').some( - ([, params]) => toRecord(params).type === 'mousePressed' - ) - ).toBe(true) - ) - driver.cancelTool('chat-test', 'hold-call') - - await expect(pending).resolves.toMatchObject({ - ok: true, - result: { outcomeUnknown: true, doNotRetry: true }, - }) - expect( - cdpCalls(contents, 'Input.dispatchMouseEvent').map(([, params]) => toRecord(params).type) - ).toContain('mouseReleased') - }) - it('rejects batches that name non-action tools or observe per action', async () => { await openPage() @@ -3683,33 +2431,6 @@ describe('credential protection', () => { expect(retried).toMatchObject({ ok: true, result: { dispatched: true } }) }) - it('names the overlay and nested controls a refused click can use next', async () => { - const contents = await openPage() - respondWith(contents, { - clickElement: { - error: 'obstructed', - blocker: 'We use cookies', - blockerControls: [{ id: 4, name: 'Accept all' }], - }, - }) - const covered = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - respondWith(contents, { - clickElement: { error: 'nested-control', blocker: 'Delete channel', controlId: 3 }, - }) - const nested = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - - expect(covered).toEqual({ - ok: false, - error: expect.stringContaining( - '[ref=4] "Accept all". Dismiss it with one of those, then retry the same id.' - ), - }) - expect(nested).toEqual({ - ok: false, - error: expect.stringContaining('Delete channel [ref=3]'), - }) - }) - it('invalidates element ids when the active tab changes', async () => { await openPage() await driver.executeTool('chat-test', 'browser_open_tab', {}) @@ -3758,61 +2479,6 @@ describe('credential protection', () => { }) }) - it('tolerates same-document URL churn during a keypress', async () => { - const contents = await openPage() - let urlReads = 0 - vi.mocked(contents.getURL).mockImplementation(() => - ++urlReads === 1 ? 'https://example.com/channel-a' : 'https://example.com/channel-b' - ) - respondWith(contents, { - activeElementSecrecy: 'safe', - readActiveElementState: {}, - readPageActionState: { - url: 'https://example.com/channel-a', - title: 'Example', - focus: 'body', - mutationRevision: 0, - dialogs: [], - scroll: [0], - }, - }) - - const result = await driver.executeTool('chat-test', 'browser_press_key', { key: 'Escape' }) - - expect(result.ok).toBe(true) - expect(cdpCalls(contents, 'Input.dispatchKeyEvent').length).toBeGreaterThan(0) - }) - - it('aborts a coordinate hover when a cross-document navigation lands mid-flight', async () => { - const contents = await openPage() - let navigated = false - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'describePointTarget')) return Promise.resolve({ found: true }) - if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({}) - if (isPageCall(expression, 'readPageActionState')) { - if (!navigated) { - navigated = true - emitContentsEvent(contents, 'did-navigate') - } - return Promise.resolve({ - url: 'https://example.com/login', - title: 'Example', - focus: 'body', - mutationRevision: 0, - dialogs: [], - scroll: [0], - }) - } - return Promise.resolve(undefined) - }) - - const result = await driver.executeTool('chat-test', 'browser_hover', { x: 40, y: 50 }) - - expect(result.ok).toBe(false) - expect(result.error).toMatch(/active tab or page changed/) - expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) - }) - it('aborts a keypress when a cross-document navigation lands mid-flight', async () => { const contents = await openPage() let navigated = false @@ -3843,58 +2509,6 @@ describe('credential protection', () => { expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(0) }) - it('waits for a late-mounting editor before typing', async () => { - const contents = await openPage() - let focusReads = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'focusElementForTyping')) { - focusReads++ - return Promise.resolve( - focusReads === 1 - ? { error: 'not-editable' } - : { focused: true, kind: 'contenteditable', x: 24, y: 48 } - ) - } - if (isPageCall(expression, 'activeElementSecrecy')) return Promise.resolve('safe') - if (isPageCall(expression, 'readActiveElementState')) { - return Promise.resolve({ activeElement: 'div', valueLength: 5 }) - } - return Promise.resolve(undefined) - }) - - const result = await driver.executeTool('chat-test', 'browser_type', { - elementId: 0, - text: 'hello', - }) - - expect(result.ok).toBe(true) - expect(focusReads).toBeGreaterThan(1) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) - }) - - it('reprobes a transiently stale click target before giving up', async () => { - const contents = await openPage() - let clickReads = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'clickElement')) { - clickReads++ - return Promise.resolve( - clickReads === 1 - ? { error: 'stale' } - : { dispatched: false, x: 24, y: 48, element: 'Channel row' } - ) - } - if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({}) - if (isPageCall(expression, 'readPageActionState')) return Promise.resolve({}) - return Promise.resolve(undefined) - }) - - const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - - expect(result).toMatchObject({ ok: true, result: { dispatched: true } }) - expect(clickReads).toBeGreaterThan(1) - }) - it('clicks a coordinate point with native input and reports the target', async () => { const contents = await openPage() respondWith(contents, { @@ -3927,73 +2541,6 @@ describe('credential protection', () => { expect(presses).toHaveLength(1) }) - it('double-clicks a coordinate point as a rising clickCount sequence', async () => { - const contents = await openPage() - respondWith(contents, { - describePointTarget: { found: true, element: 'canvas', editable: false }, - readActiveElementState: {}, - readPageActionState: {}, - }) - - const result = await driver.executeTool('chat-test', 'browser_click_at', { - x: 10, - y: 20, - clickCount: 2, - }) - - expect(result).toMatchObject({ ok: true, result: { clickCount: 2 } }) - const counts = cdpCalls(contents, 'Input.dispatchMouseEvent') - .filter(([, event]) => (event as { type?: string }).type === 'mousePressed') - .map(([, event]) => (event as { clickCount?: number }).clickCount) - expect(counts).toEqual([1, 2]) - }) - - it('dispatches right-clicks and modifier clicks through the same trusted gesture', async () => { - const contents = await openPage() - respondWith(contents, { - describePointTarget: { found: true, element: 'row "notes.pdf"', editable: false }, - readActiveElementState: {}, - readPageActionState: {}, - }) - - const result = await driver.executeTool('chat-test', 'browser_click_at', { - x: 10, - y: 20, - button: 'right', - modifiers: ['Shift'], - }) - - expect(result).toMatchObject({ ok: true, result: { dispatched: true } }) - const presses = cdpCalls(contents, 'Input.dispatchMouseEvent').filter( - ([, event]) => (event as { type?: string }).type === 'mousePressed' - ) - expect(presses.map(([, event]) => event)).toEqual([ - expect.objectContaining({ button: 'right', buttons: 2, modifiers: 8, clickCount: 1 }), - ]) - }) - - it('rejects unknown click buttons and modifiers before dispatch', async () => { - const contents = await openPage() - - const badButton = await driver.executeTool('chat-test', 'browser_click_at', { - x: 10, - y: 20, - button: 'back', - }) - const badModifier = await driver.executeTool('chat-test', 'browser_click_at', { - x: 10, - y: 20, - modifiers: ['Hyper'], - }) - - expect(badButton).toMatchObject({ ok: false, error: expect.stringContaining('button must be') }) - expect(badModifier).toMatchObject({ - ok: false, - error: expect.stringContaining('Unrecognized modifier'), - }) - expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) - }) - it('answers a dialog opened by an action with that action dialog response only', async () => { const contents = await openPage() respondWith(contents, { @@ -4070,19 +2617,6 @@ describe('credential protection', () => { ]) }) - it('rejects a malformed dialog response before dispatch', async () => { - const contents = await openPage() - - const result = await driver.executeTool('chat-test', 'browser_click_at', { - x: 10, - y: 20, - dialog: { accept: 'yes' }, - }) - - expect(result).toMatchObject({ ok: false, error: expect.stringContaining('dialog must be') }) - expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) - }) - describe('file uploads', () => { afterEach(() => vi.restoreAllMocks()) @@ -4127,28 +2661,6 @@ describe('credential protection', () => { expect(resolve).toHaveBeenCalledTimes(1) }) - it('refuses several files for a single-file input and releases its handle without staging', async () => { - const contents = await openPage() - const input = { objectId: 'isolated-input', multiple: false } - vi.spyOn(cdp, 'resolveFileInput').mockResolvedValue(input) - const release = vi.spyOn(cdp, 'releaseFileInput').mockResolvedValue() - stageUploadFiles.mockClear() - - const result = await driver.executeTool( - 'chat-test', - 'browser_upload_file', - { elementId: 0, paths: ['files/a.pdf', 'files/b.pdf'] }, - 'call-single' - ) - - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining('accepts one file'), - }) - expect(stageUploadFiles).not.toHaveBeenCalled() - expect(release).toHaveBeenCalledWith(contents, input) - }) - it.each(['staging', 'attachment'])( 'releases the pinned input after %s fails before dispatch', async (failure) => { @@ -4180,164 +2692,6 @@ describe('credential protection', () => { } ) - it('reports an acknowledged upload with unavailable readback without inviting a retry', async () => { - const contents = await openPage() - const input = { objectId: 'isolated-input', multiple: false } - vi.spyOn(cdp, 'resolveFileInput').mockResolvedValue(input) - vi.spyOn(cdp, 'setFileInputFiles').mockResolvedValue({ - readbackError: 'Execution context destroyed', - }) - const release = vi.spyOn(cdp, 'releaseFileInput').mockResolvedValue() - stageUploadFiles.mockResolvedValue(['/staged/a.pdf']) - - const result = await driver.executeTool( - 'chat-test', - 'browser_upload_file', - { elementId: 0, paths: ['files/a.pdf'] }, - 'call-navigated' - ) - - expect(result).toMatchObject({ - ok: true, - result: { - dispatched: true, - observation: { ok: false, doNotRetry: true, error: 'Execution context destroyed' }, - }, - }) - expect(release).toHaveBeenCalledWith(contents, input) - }) - - it.each(['cancelled', 'timed out'] as const)( - 'does not retry a dispatched upload when acknowledgement is %s', - async (stop) => { - const contents = await openPage() - const input = { objectId: 'isolated-input', multiple: false } - vi.spyOn(cdp, 'resolveFileInput').mockResolvedValue(input) - stageUploadFiles.mockResolvedValue(['/staged/a.pdf']) - let acknowledge: () => void = () => {} - const acknowledgement = new Promise((resolve) => { - acknowledge = resolve - }) - const send = vi.mocked(contents.debugger.sendCommand) - send.mockImplementation(async (method, params) => { - if (method === 'Runtime.callFunctionOn') { - const mode = (params?.arguments as Array<{ value: string }>)[0].value - return mode === 'input' - ? { result: { objectId: 'original-input' } } - : { result: { value: { files: [{ name: 'a.pdf', size: 3 }] } } } - } - if (method === 'DOM.setFileInputFiles') await acknowledgement - return {} - }) - vi.useFakeTimers() - try { - const pending = driver.executeTool( - 'chat-test', - 'browser_upload_file', - { elementId: 0, paths: ['files/a.pdf'] }, - 'unacknowledged-upload' - ) - await vi.advanceTimersByTimeAsync(200) - expect(cdpCalls(contents, 'DOM.setFileInputFiles')).toHaveLength(1) - expect(cdpCalls(contents, 'Runtime.releaseObject')).toHaveLength(0) - - if (stop === 'cancelled') driver.cancelTool('chat-test', 'unacknowledged-upload') - else - await vi.advanceTimersByTimeAsync( - driver.browserToolWatchdogMs('browser_upload_file', {})! - ) - - await expect(pending).resolves.toMatchObject({ - ok: true, - result: { - outcomeUnknown: true, - doNotRetry: true, - }, - }) - await expect( - driver.executeTool('chat-test', 'browser_list_tabs', {}, 'after-unacknowledged-upload') - ).resolves.toMatchObject({ ok: true }) - } finally { - acknowledge() - await vi.advanceTimersByTimeAsync(200) - vi.useRealTimers() - } - expect(cdpCalls(contents, 'DOM.setFileInputFiles')).toHaveLength(1) - expect(cdpCalls(contents, 'Runtime.releaseObject').map(([, params]) => params)).toEqual([ - { objectId: 'original-input' }, - { objectId: 'isolated-input' }, - ]) - } - ) - - it.each(['cancelled', 'timed out'] as const)( - 'retains an acknowledged upload when readback is %s and releases its handle when readback settles', - async (stop) => { - const contents = await openPage() - respondWith(contents, { readPageActionState: {} }) - const input = { objectId: 'isolated-input', multiple: false } - vi.spyOn(cdp, 'resolveFileInput').mockResolvedValue(input) - let releaseReadback: (value: { files: Array<{ name: string; size: number }> }) => void = - () => {} - const readback = new Promise<{ files: Array<{ name: string; size: number }> }>( - (resolve) => { - releaseReadback = resolve - } - ) - const setFiles = vi - .spyOn(cdp, 'setFileInputFiles') - .mockImplementation(async (_contents, _handle, _files, _signal, onDispatch) => { - onDispatch?.('pending') - onDispatch?.('acknowledged') - return readback - }) - const release = vi.spyOn(cdp, 'releaseFileInput').mockResolvedValue() - stageUploadFiles.mockResolvedValue(['/staged/a.pdf']) - vi.useFakeTimers() - try { - const timersBefore = vi.getTimerCount() - const pending = driver.executeTool( - 'chat-test', - 'browser_upload_file', - { elementId: 0, paths: ['files/a.pdf'] }, - 'interrupted-upload' - ) - await vi.advanceTimersByTimeAsync(200) - expect(setFiles).toHaveBeenCalledTimes(1) - expect(release).not.toHaveBeenCalled() - const queued = driver.executeTool('chat-test', 'browser_list_tabs', {}, 'after-upload') - - if (stop === 'cancelled') driver.cancelTool('chat-test', 'interrupted-upload') - else - await vi.advanceTimersByTimeAsync( - driver.browserToolWatchdogMs('browser_upload_file', {})! - ) - - await expect(pending).resolves.toMatchObject({ - ok: true, - result: { - dispatched: true, - observation: { - ok: false, - doNotRetry: true, - note: expect.stringContaining('The action was dispatched'), - }, - }, - }) - await expect(queued).resolves.toMatchObject({ ok: true }) - expect(vi.getTimerCount()).toBe(timersBefore) - expect(setFiles).toHaveBeenCalledTimes(1) - expect(release).not.toHaveBeenCalled() - } finally { - releaseReadback({ files: [{ name: 'a.pdf', size: 3 }] }) - await vi.advanceTimersByTimeAsync(200) - vi.useRealTimers() - } - expect(release).toHaveBeenCalledExactlyOnceWith(contents, input) - expect(setFiles).toHaveBeenCalledTimes(1) - } - ) - it.each(['cancelled', 'timed out'] as const)( 'reports an unconfirmed upload when its acknowledgment is %s without replaying it or affecting queued work', async (stop) => { @@ -4490,306 +2844,68 @@ describe('credential protection', () => { expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) }) - it('rejects a coordinate click outside the viewport with mapping guidance', async () => { - const contents = await openPage() - respondWith(contents, { - describePointTarget: { error: 'outside-viewport' }, - }) - - const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 9999, y: 5 }) - - expect(result.ok).toBe(false) - expect(result.error).toMatch(/X\/Y coordinate mapping and crop origin/) - }) - - it('inserts text into the focused editable at the caret', async () => { - const contents = await openPage() - respondWith(contents, { - activeElementSecrecy: 'safe', - describeFocusedEditable: { editable: true, kind: 'contenteditable' }, - readActiveElementState: { activeElement: 'div', valueLength: 12 }, - readPageActionState: {}, - }) - - const result = await driver.executeTool('chat-test', 'browser_insert_text', { - text: 'hello world', - }) - - expect(result).toMatchObject({ - ok: true, - result: { dispatched: true, trusted: true, kind: 'contenteditable', insertedChars: 11 }, - }) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) - }) - - it('reports top-page effects observed after inserting text in a child frame', async () => { - const contents = await openPage() - const mainFrame = { - frameTreeNodeId: 1, - detached: false, - isDestroyed: vi.fn(() => false), - origin: 'https://example.com', - parent: null, - framesInSubtree: [] as unknown[], - } - const childFrame = { - frameTreeNodeId: 2, - detached: false, - isDestroyed: vi.fn(() => false), - origin: 'https://mail-widget.example', - parent: mainFrame, - url: 'https://mail-widget.example/compose', - } - mainFrame.framesInSubtree = [mainFrame, childFrame] - Object.defineProperty(contents, 'mainFrame', { configurable: true, value: mainFrame }) - Object.defineProperty(contents, 'focusedFrame', { configurable: true, value: childFrame }) - let topPageReads = 0 - vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { - if (isPageCall(expression, 'readPageActionState')) { - topPageReads++ - return Promise.resolve({ - url: - topPageReads === 1 ? 'https://example.com/compose' : 'https://example.com/message/sent', - title: 'Mail', - focus: 'iframe', - mutationRevision: topPageReads, - dialogs: [], - scroll: [0], - }) - } - return Promise.resolve(undefined) - }) - const isolatedFrameEval = vi - .spyOn(cdp, 'evaluateInIsolatedFrame') - .mockImplementation((_contents, frame, expression) => { - if ((frame as unknown) === mainFrame) return contents.executeJavaScript(expression) - if (isPageCall(expression, 'activeElementSecrecy')) return Promise.resolve('safe') - if (isPageCall(expression, 'describeFocusedEditable')) { - return Promise.resolve({ editable: true, kind: 'input' }) - } - if (isPageCall(expression, 'readActiveElementState')) { - return Promise.resolve({ activeElement: 'input', valueLength: 4 }) - } - if (isPageCall(expression, 'readPageActionState')) { - return Promise.resolve({ - url: 'https://mail-widget.example/compose', - title: 'Compose', - focus: 'input', - mutationRevision: 0, - dialogs: [], - scroll: [0], - }) - } - return Promise.resolve(undefined) - }) - - try { - const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'sent' }) - - expect(result.ok, result.error).toBe(true) - expect(result).toMatchObject({ - ok: true, - result: { - effectObserved: true, - possibleEffectObserved: true, - effect: { urlChanged: true }, - }, - }) - } finally { - isolatedFrameEval.mockRestore() - } - }) - - it('refuses insertion when nothing editable holds focus', async () => { - const contents = await openPage() - respondWith(contents, { - activeElementSecrecy: 'safe', - describeFocusedEditable: { editable: false, reason: 'none' }, - }) - - const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' }) - - expect(result.ok).toBe(false) - expect(result.error).toMatch(/No element is focused/) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) - }) - - it('refuses insertion while a password field holds focus', async () => { - const contents = await openPage() - respondWith(contents, { activeElementSecrecy: 'secret' }) - - const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' }) - - expect(result.ok).toBe(false) - expect(result.error).toMatch(/Refusing to act on a password field/) - expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) - }) - - it('drags between coordinate points through the trusted pointer pipeline', async () => { - const contents = await openPage() - respondWith(contents, { - describePointTarget: { found: true, element: 'div "Card"' }, - readActiveElementState: {}, - readPageActionState: {}, - }) - - const result = await driver.executeTool('chat-test', 'browser_drag', { - fromX: 40, - fromY: 50, - toX: 200, - toY: 260, - }) - - expect(result).toMatchObject({ - ok: true, - result: { dispatched: true, trusted: true, from: { x: 40, y: 50 }, to: { x: 200, y: 260 } }, - }) - const events = cdpCalls(contents, 'Input.dispatchMouseEvent').map( - ([, event]) => (event as { type?: string }).type - ) - expect(events[0]).toBe('mouseMoved') - expect(events).toContain('mousePressed') - expect(events[events.length - 1]).toBe('mouseReleased') - expect(cdpCalls(contents, 'Input.setInterceptDrags').length).toBeGreaterThan(0) - }) - - it('drags through via points at the requested pace', async () => { - const contents = await openPage() - respondWith(contents, { - describePointTarget: { found: true, element: 'div "Card"' }, - readActiveElementState: {}, - readPageActionState: {}, - }) - - const result = await driver.executeTool('chat-test', 'browser_drag', { - fromX: 40, - fromY: 50, - toX: 200, - toY: 260, - via: [{ x: 300, y: 50 }], - durationMs: 320, - }) - - expect(result).toMatchObject({ ok: true, result: { dispatched: true } }) - const moves = cdpCalls(contents, 'Input.dispatchMouseEvent') - .map(([, event]) => event as { type?: string; x?: number; y?: number; buttons?: number }) - .filter((event) => event.type === 'mouseMoved' && event.buttons === 1) - expect(moves.some((event) => event.x === 300 && event.y === 50)).toBe(true) - expect(moves.length).toBeGreaterThanOrEqual(20) - }) - - it('moves the pointer along a coordinate hover path with no button pressed', async () => { - const contents = await openPage() - respondWith(contents, { - describePointTarget: { found: true, element: 'canvas "Field"' }, - readActiveElementState: {}, - readPageActionState: {}, - }) - - const result = await driver.executeTool('chat-test', 'browser_hover', { - x: 400, - y: 300, - via: [ - { x: 100, y: 300 }, - { x: 250, y: 150 }, - ], - }) - - expect(result).toMatchObject({ ok: true, result: { hovered: true, x: 400, y: 300 } }) - const events = cdpCalls(contents, 'Input.dispatchMouseEvent').map( - ([, event]) => event as { type?: string; x?: number; y?: number; button?: string } - ) - expect(events.every((event) => event.type === 'mouseMoved' && event.button === 'none')).toBe( - true - ) - expect(events[0]).toMatchObject({ x: 100, y: 300 }) - expect(events.some((event) => event.x === 250 && event.y === 150)).toBe(true) - expect(events[events.length - 1]).toMatchObject({ x: 400, y: 300 }) - }) - - it('rejects pointer paths where they cannot apply', async () => { - await openPage() + it('refuses insertion while a password field holds focus', async () => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'secret' }) - const elementHover = await driver.executeTool('chat-test', 'browser_hover', { - elementId: 0, - via: [{ x: 1, y: 2 }], - }) - const unroutedPace = await driver.executeTool('chat-test', 'browser_hover', { - x: 10, - y: 10, - durationMs: 800, - }) - const badPoint = await driver.executeTool('chat-test', 'browser_hover', { - x: 10, - y: 10, - via: [{ x: 'left', y: 2 }], - }) - const tooLong = await driver.executeTool('chat-test', 'browser_drag', { - fromX: 1, - fromY: 1, - toX: 50, - toY: 50, - durationMs: 10_001, - }) - const batched = await driver.executeTool('chat-test', 'browser_batch', { - actions: [ - { tool: 'browser_click', args: { elementId: 0 } }, - { tool: 'browser_hover', args: { x: 5, y: 5, durationMs: 500 } }, - ], - }) + const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' }) - expect(elementHover).toMatchObject({ - ok: false, - error: expect.stringContaining('coordinate hover'), - }) - expect(unroutedPace).toMatchObject({ - ok: false, - error: expect.stringContaining('paces a hover route'), - }) - expect(badPoint).toMatchObject({ ok: false, error: expect.stringContaining('via point') }) - expect(tooLong).toMatchObject({ ok: false, error: expect.stringContaining('durationMs') }) - expect(batched).toMatchObject({ - ok: false, - error: expect.stringContaining('timed pointer path'), - }) + expect(result.ok).toBe(false) + expect(result.error).toMatch(/Refusing to act on a password field/) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) }) - it('drags from a snapshot element to a coordinate target', async () => { + it('drags between coordinate points through the trusted pointer pipeline', async () => { const contents = await openPage() respondWith(contents, { - clickElement: { dispatched: false, x: 24, y: 48, element: 'Card "Ship it"' }, - describePointTarget: { found: true, element: 'section "Done"' }, + describePointTarget: { found: true, element: 'div "Card"' }, readActiveElementState: {}, readPageActionState: {}, }) const result = await driver.executeTool('chat-test', 'browser_drag', { - fromElementId: 0, - toX: 300, - toY: 60, + fromX: 40, + fromY: 50, + toX: 200, + toY: 260, }) expect(result).toMatchObject({ ok: true, - result: { dispatched: true, from: { x: 24, y: 48, element: 'Card "Ship it"' } }, + result: { dispatched: true, trusted: true, from: { x: 40, y: 50 }, to: { x: 200, y: 260 } }, }) + const events = cdpCalls(contents, 'Input.dispatchMouseEvent').map( + ([, event]) => (event as { type?: string }).type + ) + expect(events[0]).toBe('mouseMoved') + expect(events).toContain('mousePressed') + expect(events[events.length - 1]).toBe('mouseReleased') + expect(cdpCalls(contents, 'Input.setInterceptDrags').length).toBeGreaterThan(0) }) - it('rejects a drag whose endpoints are the same point', async () => { + it('drags through via points at the requested pace', async () => { const contents = await openPage() respondWith(contents, { - describePointTarget: { found: true, element: 'div' }, + describePointTarget: { found: true, element: 'div "Card"' }, + readActiveElementState: {}, + readPageActionState: {}, }) const result = await driver.executeTool('chat-test', 'browser_drag', { - fromX: 10, - fromY: 10, - toX: 10, - toY: 10, + fromX: 40, + fromY: 50, + toX: 200, + toY: 260, + via: [{ x: 300, y: 50 }], + durationMs: 320, }) - expect(result.ok).toBe(false) - expect(result.error).toMatch(/same point/) + expect(result).toMatchObject({ ok: true, result: { dispatched: true } }) + const moves = cdpCalls(contents, 'Input.dispatchMouseEvent') + .map(([, event]) => event as { type?: string; x?: number; y?: number; buttons?: number }) + .filter((event) => event.type === 'mouseMoved' && event.buttons === 1) + expect(moves.some((event) => event.x === 300 && event.y === 50)).toBe(true) + expect(moves.length).toBeGreaterThanOrEqual(20) }) it('finds only fresh ref-bearing snapshot lines with literal text matching', async () => { @@ -4819,24 +2935,6 @@ describe('credential protection', () => { }) }) - it.each([ - ['browser_snapshot', 'true'], - ['browser_find', 'false'], - ] as const)( - 'passes an absent scope as null to the serialized %s page call', - async (tool, markNew) => { - const contents = await openPage() - vi.mocked(contents.executeJavaScript).mockClear() - await driver.executeTool('chat-test', tool, { query: 'Continue' }) - const expressions = vi - .mocked(contents.executeJavaScript) - .mock.calls.map(([expression]) => expression) - .filter((expression) => isPageCall(expression, 'collectSnapshot')) - expect(expressions).toHaveLength(1) - expect(expressions[0]).toContain(`.apply(null, [1,null,${markNew}])`) - } - ) - it.each([ ['browser_snapshot', 'true'], ['browser_find', 'false'], @@ -4872,65 +2970,6 @@ describe('credential protection', () => { } ) - it.each([ - [1, false, 1], - [100, false, 50], - [100, true, 50], - ])( - 'bounds find results for maxResults=%s and snapshot truncation=%s', - async (maxResults, truncated, expectedCount) => { - const contents = await openPage() - const ids = Array.from({ length: 60 }, (_, index) => index + 1) - respondWith(contents, { - collectSnapshot: { - url: 'https://example.com/login', - title: 'Example', - outline: ids.map((id) => `- button "Continue ${id}" [ref=${id}]`).join('\n'), - truncated, - refIds: ids, - refLineIndexes: Object.fromEntries(ids.map((id, index) => [id, index])), - nextElementId: 61, - }, - }) - const response = await driver.executeTool('chat-test', 'browser_find', { - query: 'Continue', - maxResults, - }) - expect(response).toMatchObject({ ok: true, result: { totalMatches: 60, truncated: true } }) - expect((response.result as { matches: unknown[] }).matches).toHaveLength(expectedCount) - } - ) - - it('reports incomplete search coverage even when no ref matches', async () => { - const contents = await openPage() - respondWith(contents, { - collectSnapshot: { - url: 'https://example.com/login', - title: 'Example', - outline: '- button "Other" [ref=1]', - truncated: true, - refIds: [1], - refLineIndexes: { 1: 0 }, - nextElementId: 2, - }, - }) - const response = await driver.executeTool('chat-test', 'browser_find', { query: 'Missing' }) - expect(response).toMatchObject({ - ok: true, - result: { matches: [], totalMatches: 0, truncated: true }, - }) - }) - - it('rejects oversized search text before taking a snapshot', async () => { - const contents = await openPage() - vi.mocked(contents.executeJavaScript).mockClear() - const response = await driver.executeTool('chat-test', 'browser_find', { - query: 'x'.repeat(4097), - }) - expect(response).toMatchObject({ ok: false }) - expect(contents.executeJavaScript).not.toHaveBeenCalled() - }) - it.each([ { kind: 'input:checkbox', checked: true, disabled: false, readOnly: false }, { kind: 'input:radio', checked: false, disabled: false, readOnly: false }, @@ -5019,27 +3058,6 @@ describe('credential protection', () => { } ) - it('waits for URL and semantic element state together', async () => { - const contents = await openPage() - respondWith(contents, { - readPageActionState: { - targetState: { present: true, rendered: true, disabled: false }, - }, - }) - - const result = await driver.executeTool('chat-test', 'browser_wait_for', { - urlContains: '/login', - elementId: 0, - state: 'enabled', - timeoutMs: 1_000, - }) - - expect(result).toMatchObject({ - ok: true, - result: { found: true, matched: ['url', 'element'] }, - }) - }) - it.each([true, false])( 'polls delayed checkable state without redispatching input (updates=%s)', async (updates) => { @@ -5098,93 +3116,6 @@ describe('credential protection', () => { } ) - it('rejects navigation while inspecting an element wait condition', async () => { - const contents = await openPage() - vi.mocked(contents.executeJavaScript).mockImplementation(async () => { - vi.mocked(contents.getURL).mockReturnValue('https://example.com/next') - return { targetState: { present: false, rendered: false } } - }) - const response = await driver.executeTool('chat-test', 'browser_wait_for', { - elementId: 0, - state: 'detached', - timeoutMs: 1000, - }) - expect(response).toMatchObject({ ok: false }) - expect(response.error).toContain('page changed') - }) - - it.each([ - ['detached', { present: true, rendered: false }], - ['collapsed', { present: true, rendered: true }], - ['expanded', { present: true, rendered: true }], - ['unchecked', { present: true, rendered: true, checked: 'mixed' }], - ['unchecked', { present: true, rendered: true }], - ])('does not satisfy %s from an incompatible element state', async (state, targetState) => { - const contents = await openPage() - respondWith(contents, { readPageActionState: { targetState } }) - vi.useFakeTimers() - try { - const response = driver.executeTool('chat-test', 'browser_wait_for', { - elementId: 0, - state, - timeoutMs: 100, - }) - await vi.advanceTimersByTimeAsync(1000) - await expect(response).resolves.toMatchObject({ ok: true, result: { found: false } }) - } finally { - vi.useRealTimers() - } - }) - - it.each([ - ['expanded', { open: true }], - ['collapsed', { open: false }], - ['expanded', { ariaExpanded: 'true' }], - ['collapsed', { ariaExpanded: 'false' }], - ])('waits for %s on native and ARIA disclosure controls', async (state, semanticState) => { - const contents = await openPage() - respondWith(contents, { - readPageActionState: { targetState: { present: true, rendered: true, ...semanticState } }, - }) - await expect( - driver.executeTool('chat-test', 'browser_wait_for', { - elementId: 0, - state, - timeoutMs: 100, - }) - ).resolves.toMatchObject({ ok: true, result: { found: true } }) - }) - - it.each(['x', 'y', 'width', 'height', 'detached'])( - 'rejects an element screenshot when %s changes during capture', - async (change) => { - const contents = await openPage() - let captured = false - vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => { - if (!isPageCall(expression, 'getElementScreenshotRect')) return undefined - if (captured && change === 'detached') return { error: 'stale' } - return { x: 20, y: 30, width: 200, height: 100, ...(captured ? { [change]: 50 } : {}) } - }) - const capture = vi.spyOn(cdp, 'captureScreenshot').mockImplementation(async () => { - captured = true - return { - dataUrl: 'data:image/jpeg;base64,c2lt', - scale: 1, - viewport: { width: 800, height: 600 }, - imageSize: { width: 200, height: 100 }, - } - }) - try { - const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 }) - expect(result.ok).toBe(false) - expect(result.error).toMatch(change === 'detached' ? /stale/ : /element moved/) - expect(capture).toHaveBeenCalledTimes(1) - } finally { - capture.mockRestore() - } - } - ) - it('crops an element screenshot without changing the live viewport', async () => { const contents = await openPage() respondWith(contents, { @@ -5221,63 +3152,6 @@ describe('credential protection', () => { } }) - it('returns the encoded crop geometry while checking the original element bounds for movement', async () => { - const contents = await openPage() - const measuredClip = { x: 0.1, y: 0.2, width: 1.1, height: 100 } - const capturedClip = { x: 0, y: 0, width: 1.5, height: 100.5 } - respondWith(contents, { - getElementScreenshotRect: { ...measuredClip, element: 'div', refRecovered: false }, - }) - const capture = vi.spyOn(cdp, 'captureScreenshot').mockResolvedValue({ - dataUrl: 'data:image/jpeg;base64,c2lt', - scale: 2, - viewport: { width: 800, height: 600 }, - imageSize: { width: 3, height: 201 }, - clip: capturedClip, - }) - - try { - const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 }) - - expect(capture).toHaveBeenCalledWith(contents, measuredClip, expect.any(AbortSignal)) - expect(result).toMatchObject({ - ok: true, - result: { - element: 'div', - clip: capturedClip, - scale: 2, - imageSize: { width: 3, height: 201 }, - }, - }) - expect( - vi - .mocked(contents.executeJavaScript) - .mock.calls.filter(([expression]) => isPageCall(expression, 'getElementScreenshotRect')) - ).toHaveLength(2) - } finally { - capture.mockRestore() - } - }) - - it('rejects navigation during an element screenshot measurement', async () => { - const contents = await openPage() - vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => { - if (isPageCall(expression, 'getElementScreenshotRect')) { - vi.mocked(contents.getURL).mockReturnValue('https://example.com/next') - return { x: 20, y: 30, width: 200, height: 100 } - } - }) - const capture = vi.spyOn(cdp, 'captureScreenshot') - try { - const result = await driver.executeTool('chat-test', 'browser_screenshot', { elementId: 0 }) - expect(result).toMatchObject({ ok: false }) - expect(result.error).toMatch(/page changed/) - expect(capture).not.toHaveBeenCalled() - } finally { - capture.mockRestore() - } - }) - it('zooms by a standard step and invalidates existing element refs', async () => { const contents = await openPage() @@ -5374,79 +3248,6 @@ describe('credential protection', () => { ).toBe(true) }) - it('accepts stable truncated page identity with deprecated device metrics', async () => { - const contents = await openPage() - const fullUrl = `https://example.com/${'u'.repeat(5000)}` - const fullTitle = `Example ${'t'.repeat(600)}` - vi.mocked(contents.getURL).mockReturnValue(fullUrl) - vi.mocked(contents.getTitle).mockReturnValue(fullTitle) - mockScreenshotImage(contents, { width: 1024, height: 512 }) - vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { - if (method === 'Page.getLayoutMetrics') { - return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) - } - return Promise.resolve(undefined) - }) - respondWith(contents, { - getViewportInfo: { - url: fullUrl.slice(0, 4096), - title: fullTitle.slice(0, 500), - width: 1024, - height: 512, - }, - }) - - const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) - - expect(result).toMatchObject({ - ok: true, - result: { - scale: 1, - viewport: { - url: fullUrl.slice(0, 4096), - title: fullTitle.slice(0, 500), - width: 1024, - height: 512, - }, - }, - }) - }) - - it('rejects an empty screenshot instead of returning an unverified scale', async () => { - const contents = await openPage() - mockScreenshotImage(contents, null) - vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { - if (method === 'Page.getLayoutMetrics') { - return Promise.resolve({ - cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 }, - }) - } - return Promise.resolve(undefined) - }) - - const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) - - expect(result.ok).toBe(false) - expect(result.error).toMatch(/empty image/) - }) - - it('rejects a screenshot when no CSS viewport can be established', async () => { - const contents = await openPage() - mockScreenshotImage(contents, { width: 1024, height: 512 }) - vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { - if (method === 'Page.getLayoutMetrics') { - return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } }) - } - return Promise.resolve(undefined) - }) - respondWith(contents, { getViewportInfo: null }) - - const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) - - expect(result.ok).toBe(false) - expect(result.error).toMatch(/verify the page viewport/) - }) - it('rejects coordinate mapping when the viewport changes during capture', async () => { const contents = await openPage() mockScreenshotImage(contents, { width: 1024, height: 256 }) @@ -5494,38 +3295,4 @@ describe('credential protection', () => { expect(result.ok).toBe(false) expect(result.error).toMatch(/page changed while its screenshot was being captured/) }) - - it.each(['url', 'title'] as const)( - 'rejects a screenshot when the page %s changes during capture', - async (identityField) => { - const contents = await openPage() - mockScreenshotImage(contents, { width: 1024, height: 512 }) - const initialUrl = contents.getURL() - const initialTitle = contents.getTitle() - let currentUrl = initialUrl - let currentTitle = initialTitle - vi.mocked(contents.getURL).mockImplementation(() => currentUrl) - vi.mocked(contents.getTitle).mockImplementation(() => currentTitle) - vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { - if (method === 'Page.getLayoutMetrics') { - return Promise.resolve({ - cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 }, - }) - } - return Promise.resolve(undefined) - }) - - const image = await contents.capturePage() - vi.mocked(contents.capturePage).mockImplementation(async () => { - if (identityField === 'url') currentUrl = 'https://example.com/changed' - else currentTitle = 'Changed title' - return image - }) - - const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) - - expect(result.ok).toBe(false) - expect(result.error).toMatch(/page changed while its screenshot was being captured/) - } - ) }) diff --git a/apps/desktop/src/main/browser-agent/file-transfer.test.ts b/apps/desktop/src/main/browser-agent/file-transfer.test.ts index 16c648da340..bc4569f37c6 100644 --- a/apps/desktop/src/main/browser-agent/file-transfer.test.ts +++ b/apps/desktop/src/main/browser-agent/file-transfer.test.ts @@ -8,11 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) import { app, type Session } from 'electron' -import { - discardStagedUploads, - saveDownloadToWorkspace, - stageUploadFiles, -} from '@/main/browser-agent/file-transfer' +import { stageUploadFiles } from '@/main/browser-agent/file-transfer' import { LocalFilesystemService } from '@/main/local-filesystem' let temp: string @@ -90,21 +86,6 @@ describe('stageUploadFiles', () => { expect(staged.endsWith('/evil')).toBe(true) }) - it('reports an app refusal and discards partial staging', async () => { - const fetch = vi.fn(async () => Response.json({ error: 'File not found' }, { status: 404 })) - - await expect( - stageUploadFiles({ - scopeId: 'chat-1', - toolCallId: 'call-2', - paths: ['files/missing.pdf'], - appSession: appSession(fetch), - localFiles: undefined, - signal, - }) - ).rejects.toThrow('Could not read that workspace file (File not found).') - }) - it('refuses a workspace file over the transfer ceiling', async () => { const oversized = new Uint8Array(BROWSER_FILE_TRANSFER_MAX_BYTES + 1) const fetch = vi.fn(async () => new Response(oversized)) @@ -174,35 +155,6 @@ describe('stageUploadFiles', () => { } }) - it('closes a local handle and discards staging when the upload is cancelled', async () => { - const local = join(temp, 'cancelled.txt') - writeFileSync(local, 'local bytes') - const handle = await open(local, 'r') - const controller = new AbortController() - controller.abort() - - try { - await expect( - stageUploadFiles({ - scopeId: 'chat-cancelled', - toolCallId: 'call-cancelled', - paths: ['user-local/Docs--m1/cancelled.txt'], - appSession: undefined, - localFiles: { - resolveGrantedFile: async () => ({ handle, name: 'cancelled.txt', size: 11 }), - }, - signal: controller.signal, - }) - ).rejects.toThrow(/aborted/) - expect(handle.fd).toBe(-1) - expect(existsSync(join(temp, 'sim-browser-uploads/chat-cancelled/call-cancelled'))).toBe( - false - ) - } finally { - await handle.close() - } - }) - it.runIf(process.platform !== 'win32')( 'preserves a granted POSIX backslash filename when staging', async () => { @@ -247,60 +199,4 @@ describe('stageUploadFiles', () => { }) ).rejects.toThrow('Local folders are unavailable') }) - - it('discards a scope staging directory', async () => { - const fetch = vi.fn(async () => new Response('x')) - const [staged] = await stageUploadFiles({ - scopeId: 'chat-9', - toolCallId: 'call-9', - paths: ['files/a.txt'], - appSession: appSession(fetch), - localFiles: undefined, - signal, - }) - - await discardStagedUploads('chat-9') - - expect(() => readFileSync(staged)).toThrow() - }) -}) - -describe('saveDownloadToWorkspace', () => { - it('stores the download under its claimed call and returns the workspace path', async () => { - const file = join(temp, 'report.csv') - writeFileSync(file, 'a,b\n1,2\n') - const fetch = vi.fn(async () => - Response.json({ path: 'files/report.csv', name: 'report.csv', size: 8 }) - ) - - const saved = await saveDownloadToWorkspace({ - appSession: appSession(fetch), - toolCallId: 'call-5', - filePath: file, - filename: 'report.csv', - signal, - }) - - expect(saved).toEqual({ path: 'files/report.csv', name: 'report.csv', size: 8 }) - const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit] - expect(url).toBe('https://sim.test/api/desktop/tool/file?toolCallId=call-5&name=report.csv') - expect(init.method).toBe('PUT') - expect(await new Response(init.body).text()).toBe('a,b\n1,2\n') - }) - - it('surfaces the app error message', async () => { - const file = join(temp, 'report.csv') - writeFileSync(file, 'x') - const fetch = vi.fn(async () => Response.json({ error: 'Storage limit' }, { status: 402 })) - - await expect( - saveDownloadToWorkspace({ - appSession: appSession(fetch), - toolCallId: 'call-6', - filePath: file, - filename: 'report.csv', - signal, - }) - ).rejects.toThrow('Could not save the download (Storage limit).') - }) }) diff --git a/apps/desktop/src/main/browser-agent/keyboard.test.ts b/apps/desktop/src/main/browser-agent/keyboard.test.ts index e71a9514b17..18c98710645 100644 --- a/apps/desktop/src/main/browser-agent/keyboard.test.ts +++ b/apps/desktop/src/main/browser-agent/keyboard.test.ts @@ -1,4 +1,3 @@ -import { toRecord } from '@sim/utils/object' import { describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -6,12 +5,10 @@ vi.mock('electron', () => import('@/test/electron-mock')) import { WebContentsView } from 'electron' import { buildKeyDispatchPlan, - cdpModifiers, dispatchKeyCombo, KeyDispatchError, modifierKeyEvents, parseKeyCombo, - parseModifiers, } from '@/main/browser-agent/keyboard' describe('parseKeyCombo', () => { @@ -38,26 +35,6 @@ describe('parseKeyCombo', () => { expect(parseKeyCombo('ControlOrMeta+K', 'darwin')).toMatchObject({ meta: true }) }) - it('parses function keys and Insert without inserting text', () => { - expect(parseKeyCombo('F2')).toMatchObject({ key: 'F2', code: 'F2', keyCode: 113 }) - expect(parseKeyCombo('Shift+F12')).toMatchObject({ key: 'F12', keyCode: 123, shift: true }) - expect(parseKeyCombo('Insert')).toMatchObject({ key: 'Insert', keyCode: 45 }) - const [down] = buildKeyDispatchPlan(parseKeyCombo('F2'), 'linux') - expect(down).toMatchObject({ type: 'rawKeyDown', key: 'F2' }) - expect(down.text).toBeUndefined() - }) - - it('parses modifier lists shared with pointer input', () => { - expect(parseModifiers(['Shift', 'Mod'], 'darwin')).toEqual({ - ctrl: false, - meta: true, - shift: true, - alt: false, - }) - expect(cdpModifiers(parseModifiers(['Mod', 'Alt'], 'linux'))).toBe(3) - expect(() => parseModifiers(['Hyper'])).toThrow(/Unrecognized modifier/) - }) - it('rejects unknown keys and modifiers', () => { expect(() => parseKeyCombo('Hyper+X')).toThrow(/Unrecognized modifier/) expect(() => parseKeyCombo('NotAKey')).toThrow(/Unrecognized key/) @@ -104,12 +81,6 @@ describe('buildKeyDispatchPlan', () => { expect(down).toMatchObject({ type: 'keyDown', text: '\r', windowsVirtualKeyCode: 13 }) }) - it('sends editing keys as rawKeyDown without text', () => { - const [down] = buildKeyDispatchPlan(parseKeyCombo('Backspace'), 'linux') - expect(down.type).toBe('rawKeyDown') - expect(down.text).toBeUndefined() - }) - it('maps Cmd shortcuts to Blink editing commands on macOS only', () => { const combo = parseKeyCombo('Cmd+A') const [macDown] = buildKeyDispatchPlan(combo, 'darwin') @@ -130,33 +101,6 @@ describe('buildKeyDispatchPlan', () => { expect(linuxDown.commands).toBeUndefined() }) - it('does not rewrite non-editing Control combos on macOS', () => { - const [down] = buildKeyDispatchPlan(parseKeyCombo('Control+K'), 'darwin') - expect(down.modifiers).toBe(2) - expect(down.commands).toBeUndefined() - }) - - it('uses the Chromium punctuation descriptor for Cmd+,', () => { - const [down, up] = buildKeyDispatchPlan(parseKeyCombo('Cmd+,'), 'darwin') - - expect(down).toMatchObject({ - type: 'rawKeyDown', - key: ',', - code: 'Comma', - windowsVirtualKeyCode: 188, - modifiers: 4, - }) - expect(down).not.toHaveProperty('nativeVirtualKeyCode') - expect(up).not.toHaveProperty('nativeVirtualKeyCode') - }) - - it('maps Cmd+Shift+Z to redo and Cmd+Z to undo on macOS', () => { - const [redo] = buildKeyDispatchPlan(parseKeyCombo('Cmd+Shift+Z'), 'darwin') - expect(redo.commands).toEqual(['redo']) - const [undo] = buildKeyDispatchPlan(parseKeyCombo('Cmd+Z'), 'darwin') - expect(undo.commands).toEqual(['undo']) - }) - it('encodes the CDP modifier bitmask (Alt=1 Ctrl=2 Meta=4 Shift=8)', () => { const [down] = buildKeyDispatchPlan(parseKeyCombo('Control+Shift+K'), 'linux') expect(down.modifiers).toBe(2 | 8) @@ -164,15 +108,6 @@ describe('buildKeyDispatchPlan', () => { expect(down.type).toBe('rawKeyDown') expect(down.text).toBeUndefined() }) - - it('sends the shifted character as text and keeps Alt-printable combos non-textual', () => { - const [shifted] = buildKeyDispatchPlan(parseKeyCombo('Shift+1'), 'linux') - expect(shifted).toMatchObject({ key: '!', code: 'Digit1', text: '!', modifiers: 8 }) - - const [alt] = buildKeyDispatchPlan(parseKeyCombo('Alt+a'), 'linux') - expect(alt).toMatchObject({ type: 'rawKeyDown', key: 'a', modifiers: 1 }) - expect(alt.text).toBeUndefined() - }) }) describe('modifierKeyEvents', () => { @@ -198,37 +133,6 @@ describe('modifierKeyEvents', () => { expect.objectContaining({ type: 'keyUp', key: 'Control', modifiers: 0 }), ]) }) - - it('presses a bare modifier as its own key with its flag set', () => { - const combo = parseKeyCombo('Control', 'linux') - const [down, up] = buildKeyDispatchPlan(combo, 'linux') - - expect(down).toMatchObject({ - type: 'rawKeyDown', - key: 'Control', - code: 'ControlLeft', - modifiers: 2, - }) - expect(up).toMatchObject({ type: 'keyUp', key: 'Control', modifiers: 0 }) - expect(modifierKeyEvents(combo, 'linux')).toEqual({ downs: [], ups: [] }) - }) - - it('treats modifier aliases like their canonical key', () => { - for (const [alias, key, flag] of [ - ['Ctrl', 'Control', 2], - ['Option', 'Alt', 1], - ['Cmd', 'Meta', 4], - ['Command', 'Meta', 4], - ] as const) { - const [down, up] = buildKeyDispatchPlan(parseKeyCombo(alias, 'linux'), 'linux') - expect(down).toMatchObject({ key, modifiers: flag }) - expect(up).toMatchObject({ type: 'keyUp', key, modifiers: 0 }) - } - }) - - it('sends no extra events for a key without modifiers', () => { - expect(modifierKeyEvents(parseKeyCombo('a', 'linux'), 'linux')).toEqual({ downs: [], ups: [] }) - }) }) describe('dispatchKeyCombo', () => { @@ -312,74 +216,4 @@ describe('dispatchKeyCombo', () => { expect.objectContaining({ type: 'keyUp', key: 'Enter' }), ]) }) - - it('releases only the chord keys whose press was attempted', async () => { - const contents = new WebContentsView().webContents - vi.mocked(contents.debugger.sendCommand) - .mockResolvedValueOnce({}) - .mockRejectedValueOnce(new Error('shift-down response lost')) - - await expect(dispatchKeyCombo(contents, parseKeyCombo('Ctrl+Shift+K'))).rejects.toMatchObject({ - name: KeyDispatchError.name, - keyDownDispatched: true, - }) - - const keys = vi - .mocked(contents.debugger.sendCommand) - .mock.calls.map(([, params]) => `${toRecord(params).type} ${toRecord(params).key}`) - expect(keys).toEqual(['rawKeyDown Control', 'rawKeyDown Shift', 'keyUp Shift', 'keyUp Control']) - - const early = new WebContentsView().webContents - vi.mocked(early.debugger.sendCommand).mockRejectedValueOnce(new Error('control-down lost')) - await expect(dispatchKeyCombo(early, parseKeyCombo('Ctrl+Shift+K'))).rejects.toMatchObject({ - name: KeyDispatchError.name, - }) - expect( - vi - .mocked(early.debugger.sendCommand) - .mock.calls.map(([, params]) => `${toRecord(params).type} ${toRecord(params).key}`) - ).toEqual(['rawKeyDown Control', 'keyUp Control']) - }) - - it('does not turn menu-restoration cleanup failure into a duplicate key retry signal', async () => { - const contents = new WebContentsView().webContents - vi.mocked(contents.setIgnoreMenuShortcuts).mockImplementation((ignored: boolean) => { - if (!ignored) throw new Error('menu cleanup failed') - }) - - await expect(dispatchKeyCombo(contents, parseKeyCombo('Cmd+A'))).resolves.toBeUndefined() - expect(contents.debugger.sendCommand).toHaveBeenCalledTimes(4) - }) - - it('keeps the application menu isolated until overlapping dispatches finish', async () => { - const contents = new WebContentsView().webContents - let releaseFirstDown: (() => void) | undefined - vi.mocked(contents.debugger.sendCommand).mockImplementationOnce( - () => - new Promise((resolve) => { - releaseFirstDown = () => resolve({}) - }) - ) - - const first = dispatchKeyCombo(contents, parseKeyCombo('Cmd+A')) - await Promise.resolve() - const second = dispatchKeyCombo(contents, parseKeyCombo('Cmd+Z')) - await second - - expect(contents.setIgnoreMenuShortcuts).toHaveBeenCalledTimes(1) - expect(contents.setIgnoreMenuShortcuts).toHaveBeenCalledWith(true) - - releaseFirstDown?.() - await first - - expect(contents.setIgnoreMenuShortcuts).toHaveBeenNthCalledWith(2, false) - }) - - it('does not change application-menu handling for ordinary page keys', async () => { - const contents = new WebContentsView().webContents - - await dispatchKeyCombo(contents, parseKeyCombo('Enter')) - - expect(contents.setIgnoreMenuShortcuts).not.toHaveBeenCalled() - }) }) diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index 3a10100b097..c9b6ef73e33 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -138,15 +138,6 @@ afterEach(() => { }) describe('conditional click scrolling', () => { - it('leaves a reachable target in place', () => { - const target = visible(document.createElement('button')) - document.body.append(target) - register(target) - target.scrollIntoView = vi.fn() - expect(runSerialized(clickElement, [0, false])).toMatchObject({ x: 50, y: 10 }) - expect(target.scrollIntoView).not.toHaveBeenCalled() - }) - it('rechecks the hit target after scrolling past a sticky obstruction', () => { const target = visible(document.createElement('button')) const obstruction = visible(document.createElement('div')) @@ -164,28 +155,6 @@ describe('conditional click scrolling', () => { expect(target.scrollIntoView).toHaveBeenCalledOnce() }) - it('reveals a parent control when only its nested button is initially reachable', () => { - const card = visible(document.createElement('div')) - card.setAttribute('role', 'button') - const nested = visible(document.createElement('button')) - card.append(nested) - document.body.append(card) - register(card) - let scrolled = false - card.scrollIntoView = vi.fn(() => { - scrolled = true - }) - const nestedClick = vi.fn() - nested.addEventListener('click', nestedClick) - Object.defineProperty(document, 'elementFromPoint', { - configurable: true, - value: () => (scrolled ? card : nested), - }) - expect(runSerialized(clickElement, [0, false])).toMatchObject({ x: 50, y: 10 }) - expect(card.scrollIntoView).toHaveBeenCalledOnce() - expect(nestedClick).not.toHaveBeenCalled() - }) - it('rejects a target removed by scrolling without dispatching input', () => { const target = visible(document.createElement('button')) const obstruction = visible(document.createElement('div')) @@ -307,42 +276,6 @@ describe('secret-field detection', () => { expect(focusElementForTyping(0)).toEqual({ error: 'password' }) }) - it('still allows ordinary fields and controls', () => { - document.body.innerHTML = - '' - const [text, , email] = Array.from(document.body.querySelectorAll('input, button')).map(visible) - const button = visible(document.querySelector('button') as HTMLButtonElement) - register(text, button, email) - - expect(typeIntoElement(0, 'search terms', false)).toMatchObject({ - dispatched: true, - }) - expect(clickElement(1)).toMatchObject({ dispatched: true }) - expect(focusElementForTyping(2)).toMatchObject({ focused: true }) - }) - - it('refuses readonly, disabled, and non-text fields for typing', () => { - document.body.innerHTML = ` - - - - ` - const fields = Array.from(document.querySelectorAll('input, textarea')).map((element) => - visible(element as HTMLElement) - ) - register(...fields) - - expect(focusElementForTyping(0)).toEqual({ error: 'readonly' }) - expect(typeIntoElement(0, 'change', false)).toEqual({ error: 'readonly' }) - expect(focusElementForTyping(1)).toEqual({ error: 'disabled' }) - expect(typeIntoElement(1, 'change', false)).toEqual({ error: 'disabled' }) - expect(focusElementForTyping(2)).toMatchObject({ error: 'not-editable', elementTag: 'input' }) - expect(typeIntoElement(2, 'change', false)).toMatchObject({ - error: 'not-editable', - elementTag: 'input', - }) - }) - it('detects a password field reached through a same-origin iframe', () => { // `instanceof HTMLInputElement` is realm-bound and returns false for nodes // owned by a frame, which is why detection matches on tagName instead. @@ -401,41 +334,6 @@ describe('combobox typing surfaces', () => { expect(input.value).toBe('Mondu') }) - it('keeps pointer clicks blocked with typing guidance when suggestions own the surface', () => { - const { option } = composeField() - Object.defineProperty(document, 'elementFromPoint', { - configurable: true, - value: () => option, - }) - expect(focusElementForTyping(0)).toMatchObject({ focused: true }) - - expect(clickElement(0, false)).toMatchObject({ - error: 'suggestions-open', - blocker: 'Mondu', - }) - }) - - it('does not give suggestions guidance when any click point has an unrelated blocker', () => { - const { option } = composeField() - const overlay = visible(document.createElement('div')) - overlay.setAttribute('aria-label', 'Unrelated overlay') - document.body.append(overlay) - Object.defineProperty(document, 'elementFromPoint', { - configurable: true, - value: (x: number) => (x > 70 ? overlay : option), - }) - expect(focusElementForTyping(0)).toEqual({ - error: 'obstructed', - blocker: 'Mondu', - blockerControls: [], - }) - - expect(clickElement(0, false)).toMatchObject({ - error: 'obstructed', - blocker: 'Mondu', - }) - }) - it('refuses mixed or unrelated blockers instead of treating them as suggestions', () => { const { option } = composeField() const overlay = visible(document.createElement('div')) @@ -505,72 +403,6 @@ describe('elements inside a same-origin iframe', () => { expect(field.value).toBe('hello') }) - it('focuses a framed input for native typing', () => { - const inner = framedBody('') - register(visible(inner.querySelector('input') as HTMLInputElement)) - - expect(focusElementForTyping(0)).toMatchObject({ - focused: true, - kind: 'input', - }) - }) - - it('selects an option in a framed select', () => { - const inner = framedBody( - '' - ) - const select = inner.querySelector('select') as HTMLSelectElement - register(select) - - expect(selectOptionInElement(0, 'B')).toMatchObject({ selected: 'B' }) - expect(select.value).toBe('b') - }) - - it('does not programmatically mutate disabled selects or options', () => { - const inner = framedBody(` - - - `) - const [disabledSelect, optionDisabled] = Array.from( - inner.querySelectorAll('select') - ) as HTMLSelectElement[] - register(disabledSelect, optionDisabled) - - expect(selectOptionInElement(0, 'A')).toEqual({ error: 'disabled' }) - expect(selectOptionInElement(1, 'B')).toEqual({ error: 'disabled' }) - expect(optionDisabled.value).toBe('') - }) - - it('focuses a framed element when clicking it', () => { - const inner = framedBody('') - const button = visible(inner.querySelector('button') as HTMLButtonElement) - register(button) - let focused = false - button.addEventListener('focus', () => { - focused = true - }) - - expect(clickElement(0)).toMatchObject({ dispatched: true }) - expect(focused).toBe(true) - }) - - it('does not synthesize Space after focusing a framed text input', () => { - const inner = framedBody('') - const [text, checkbox] = Array.from(inner.querySelectorAll('input')) as HTMLInputElement[] - visible(text) - visible(checkbox) - register(text, checkbox) - - expect(clickElement(0, false, true)).toMatchObject({ - dispatched: false, - activationKey: undefined, - }) - expect(clickElement(1, false, true)).toMatchObject({ - dispatched: false, - activationKey: 'Space', - }) - }) - it('still refuses a framed password field', () => { const inner = framedBody('') register(inner.querySelector('input') as HTMLInputElement) @@ -621,76 +453,6 @@ describe('collectSnapshot', () => { expect(outline).not.toContain('hunter2') }) - it('still reports ordinary input values', () => { - document.body.innerHTML = '' - visible(document.querySelector('input') as HTMLInputElement) - - expect(outlineOf(collectSnapshot())).toContain('value="tokyo"') - }) - - it('exposes roleless delegated React rows instead of dropping their text', () => { - document.body.innerHTML = '
eng-bugs
' - const row = visible(document.querySelector('div') as HTMLDivElement) - visible(document.querySelector('span') as HTMLSpanElement) - let clicked = false - row.addEventListener('click', () => { - clicked = true - }) - - const outline = outlineOf(collectSnapshot()) - const ref = refFor(outline, 'eng-bugs') - - expect(outline).toContain('clickable "eng-bugs"') - expect(clickElement(ref)).toMatchObject({ dispatched: true }) - expect(clicked).toBe(true) - }) - - it('names the covering dialog controls so the agent can dismiss it and retry', () => { - document.body.innerHTML = ` - -

We use cookies

- -
` - for (const element of Array.from(document.body.querySelectorAll('*'))) visible(element) - const outline = outlineOf(collectSnapshot()) - const ref = refFor(outline, 'Checkout') - const notice = document.querySelector('p') as HTMLParagraphElement - Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => notice }) - - expect(clickElement(ref, false)).toEqual({ - error: 'obstructed', - blocker: 'We use cookies', - blockerControls: [ - { id: refFor(outline, 'Accept all'), name: 'Accept all' }, - { id: refFor(outline, 'Close notice'), name: 'Close notice' }, - ], - }) - }) - - it('names the controls of an overlay built from a web component', () => { - document.body.innerHTML = ` - - ` - const host = document.getElementById('banner') as HTMLDivElement - const shadow = host.attachShadow({ mode: 'open' }) - shadow.innerHTML = '

We use cookies

' - for (const element of [ - ...Array.from(document.body.querySelectorAll('*')), - ...Array.from(shadow.querySelectorAll('*')), - ]) { - visible(element) - } - const outline = outlineOf(collectSnapshot()) - const ref = refFor(outline, 'Checkout') - const notice = shadow.querySelector('p') as HTMLParagraphElement - Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => notice }) - - expect(clickElement(ref, false)).toMatchObject({ - error: 'obstructed', - blockerControls: [{ id: refFor(outline, 'Close banner'), name: 'Close banner' }], - }) - }) - it('refuses a coordinate click when an overlay owns every hit point', () => { document.body.innerHTML = '
' @@ -710,86 +472,6 @@ describe('collectSnapshot', () => { }) }) - it('refuses a parent click when a nested independent control owns the hit point', () => { - document.body.innerHTML = ` -
- -
- ` - const card = visible(document.querySelector('[role="button"]') as HTMLDivElement) - const nestedButton = visible(document.querySelector('button') as HTMLButtonElement) - const ref = refFor(outlineOf(collectSnapshot()), 'Channel card') - Object.defineProperty(document, 'elementFromPoint', { - configurable: true, - value: () => nestedButton, - }) - - expect(card.contains(nestedButton)).toBe(true) - // Nothing is covering the card — its own button owns the point. Reporting - // this as an obstruction told the agent to close an overlay that does not - // exist; the recovery is to target the nested control instead. - expect(clickElement(ref, false)).toEqual({ - error: 'nested-control', - blocker: 'Delete channel', - controlId: 1, - }) - }) - - it('names an emoji gridcell from descendant image metadata', () => { - document.body.innerHTML = '
party parrot
' - visible(document.querySelector('[role="gridcell"]') as HTMLDivElement) - - expect(outlineOf(collectSnapshot())).toContain('gridcell "party parrot"') - }) - - it('names an emoji gridcell from Slack-style data metadata', () => { - document.body.innerHTML = - '
' - visible(document.querySelector('[role="gridcell"]') as HTMLDivElement) - - expect(outlineOf(collectSnapshot())).toContain('gridcell "party-parrot"') - }) - - it('reports native and ARIA control state in the snapshot', () => { - document.body.innerHTML = ` - - - - -
-
Activity
- -
- ` - for (const element of document.body.children) visible(element as HTMLElement) - document.querySelector('[aria-label="Partial selection"]')!.indeterminate = - true - const outline = outlineOf(collectSnapshot()) - - expect(outline).toMatch(/checkbox "Email alerts" \[ref=\d+\] unchecked/) - expect(outline).toMatch(/radio "Weekly" \[ref=\d+\] checked/) - expect(outline).toMatch(/checkbox "Partial selection" \[ref=\d+\] mixed/) - expect(outline).toMatch(/button "Filters" \[ref=\d+\] aria-expanded=false aria-pressed=true/) - expect(outline).toMatch(/switch "Dark mode" \[ref=\d+\] aria-checked=mixed/) - expect(outline).toMatch(/tab "Activity" \[ref=\d+\] aria-selected=true/) - expect(outline).toMatch(/textbox "Notes" \[ref=\d+\] readonly required/) - expect(outline).toMatch(/textbox "Summary" \[ref=\d+\] aria-readonly aria-required/) - }) - - it('does not duplicate every descendant of an inherited pointer target', () => { - document.body.innerHTML = ` -
- eng-bugs -
- ` - for (const element of document.querySelectorAll('*')) visible(element) - - const outline = outlineOf(collectSnapshot()) - - expect(outline.match(/clickable /g)).toHaveLength(1) - expect(outline).toContain('clickable "eng-bugs#"') - }) - it('escapes labels that try to forge snapshot ref syntax', () => { document.body.innerHTML = "" visible(document.querySelector('button') as HTMLButtonElement) @@ -844,23 +526,6 @@ describe('collectSnapshot', () => { expect(lines[0]).not.toContain('[ref=999]') }) - it('shares the text budget across inline fragments and leaves room for later controls', () => { - document.body.innerHTML = `${Array.from( - { length: 650 }, - (_, index) => `

Before ${index} inline ${index} after ${index}

` - ).join( - '' - )}${Array.from({ length: 100 }, (_, index) => ``).join('')}` - for (const element of document.querySelectorAll('*')) visible(element) - - const snapshot = collectSnapshot() as { outline: string; truncated: boolean } - expect(snapshot.truncated).toBe(true) - expect(snapshot.outline.match(/^- text /gm)).toHaveLength(120) - expect(snapshot.outline.match(/^- button /gm)).toHaveLength(100) - expect(snapshot.outline).toMatch(/button "Action 99" \[ref=\d+\]/) - expect(snapshot.outline).toMatch(/textbox "Final field" \[ref=\d+\]/) - }) - it('indexes only refs that were emitted before snapshot line truncation', () => { document.body.innerHTML = `${Array.from( { length: 599 }, @@ -901,41 +566,6 @@ describe('collectSnapshot', () => { expect(clickElement(ref)).toEqual({ error: 'file-input' }) }) - it('pins the file input behind a drop zone, label, or the input itself', () => { - document.body.innerHTML = `
Drop files
- -
` - register( - document.getElementById('zone') as HTMLElement, - document.getElementById('label') as HTMLElement, - document.getElementById('labelled') as HTMLElement, - document.getElementById('two') as HTMLElement - ) - - expect(resolveFileInputTarget(0)).toEqual({ - input: document.getElementById('hidden'), - document, - }) - expect(resolveFileInputTarget(1)).toEqual({ - input: document.getElementById('labelled'), - document, - }) - expect(resolveFileInputTarget(2)).toEqual({ - input: document.getElementById('labelled'), - document, - }) - expect(() => resolveFileInputTarget(3)).toThrow('multiple file inputs') - expect(document.querySelector('[data-sim-agent-upload]')).toBeNull() - }) - - it('reports an element with no nearby file input', () => { - document.body.innerHTML = - '
' - register(document.getElementById('b') as HTMLElement) - - expect(() => resolveFileInputTarget(0)).toThrow('no nearby file input') - }) - it.each([ ['disabled input', ''], ['disabled fieldset', '
'], @@ -960,57 +590,6 @@ describe('collectSnapshot', () => { expect(input.hasAttribute('data-sim-agent-upload')).toBe(false) }) - it('allows the first legend exemption in a disabled fieldset', () => { - document.body.innerHTML = - '
' - const input = document.querySelector('input') as HTMLInputElement - register(document.querySelector('label') as HTMLLabelElement) - - expect(runSerialized(resolveFileInputTarget, [0])).toEqual({ input, document }) - }) - - it('pins the original input inside an open shadow root without modifying the DOM', () => { - const host = document.createElement('div') - document.body.append(host) - const input = document.createElement('input') - input.type = 'file' - host.attachShadow({ mode: 'open' }).append(input) - register(host) - - expect(runSerialized(resolveFileInputTarget, [0])).toEqual({ input, document }) - expect(input.hasAttribute('data-sim-agent-upload')).toBe(false) - }) - - it('captures the actual owner document for an input reached through a same-origin frame', () => { - const frame = document.createElement('iframe') - document.body.append(frame) - const childDocument = frame.contentDocument as Document - childDocument.body.innerHTML = '' - const input = childDocument.querySelector('input') as HTMLInputElement - register(input) - - const captured = resolveFileInputTarget(0) - expect(captured).toEqual({ input, document: childDocument }) - document.body.append(input) - expect(captured.document).toBe(childDocument) - expect(captured.input.ownerDocument).toBe(document) - }) - - it('climbs out of a shadow root inside a same-origin frame to find the file input', () => { - const frame = document.createElement('iframe') - document.body.append(frame) - const childDocument = frame.contentDocument as Document - childDocument.body.innerHTML = '
' - const host = childDocument.getElementById('host') as HTMLElement - const button = childDocument.createElement('button') - host.attachShadow({ mode: 'open' }).append(button) - const input = childDocument.querySelector('input') as HTMLInputElement - register(button) - - expect(button.getRootNode()).not.toBeInstanceOf(ShadowRoot) - expect(resolveFileInputTarget(0)).toEqual({ input, document: childDocument }) - }) - it('refuses a disconnected or stale upload reference', () => { const input = document.createElement('input') input.type = 'file' @@ -1037,122 +616,6 @@ describe('collectSnapshot', () => { expect(readSelectElementState(0)).toMatchObject({ values: [] }) }) - it('captures requested labels before event handlers replace a duplicate-value option', () => { - document.body.innerHTML = - '' - const select = document.querySelector('select') as HTMLSelectElement - register(select) - select.addEventListener('change', () => { - select.options[1].selected = false - select.options[2].selected = true - select.options[1].label = 'Rewritten' - }) - expect(selectOptionInElement(0, ['Fixed', 'Wanted'])).toMatchObject({ - values: ['fixed', 'shared'], - labels: ['Fixed', 'Wanted'], - }) - expect(readSelectElementState(0)).toMatchObject({ - values: ['fixed', 'shared'], - labels: ['Fixed', 'Other'], - }) - }) - - it('does not use multiple-selection arguments on a single-selection dropdown', () => { - document.body.innerHTML = - '' - const select = document.querySelector('select') as HTMLSelectElement - register(select) - expect(selectOptionInElement(0, ['B'])).toHaveProperty('error') - expect(select.value).toBe('a') - expect(selectOptionInElement(0, 'B')).toMatchObject({ value: 'b' }) - }) - - it('keeps plain visible leaf text available as an actionable ref', () => { - document.body.innerHTML = '
announce
' - visible(document.querySelector('span') as HTMLSpanElement) - - expect(outlineOf(collectSnapshot())).toContain('text "announce" [ref=') - }) - - it('preserves mixed inline text in reading order without duplicating control labels', () => { - document.body.innerHTML = - '
Type "hello" in upper case.
' - for (const el of document.querySelectorAll('body, div, strong, button, span, b')) visible(el) - const outline = outlineOf(collectSnapshot()) - const labels = Array.from(outline.matchAll(/- text ("(?:[^"\\]|\\.)*")/g), (match) => - JSON.parse(match[1]) - ) - expect(labels).toEqual(['Type "', 'hello', '" in upper case.']) - expect(outline).toContain('button "Save draft"') - expect(outline).not.toContain('Hidden') - }) - - it('does not emit stale textarea defaults after the current value changes', () => { - document.body.innerHTML = '' - const input = visible(document.querySelector('textarea') as HTMLTextAreaElement) - input.value = 'Current draft' - expect(outlineOf(collectSnapshot())).not.toContain('Old draft') - input.value = '' - expect(outlineOf(collectSnapshot())).not.toContain('Old draft') - }) - - it('preserves direct text in open shadow roots and respects hidden hosts', () => { - document.body.innerHTML = '
' - const host = visible(document.querySelector('div') as HTMLDivElement) - const shadow = host.attachShadow({ mode: 'open' }) - shadow.innerHTML = 'Before middle after' - visible(shadow.querySelector('strong') as HTMLElement) - const outline = outlineOf(collectSnapshot()) - expect(outline.indexOf('text "Before"')).toBeLessThan(outline.indexOf('text "middle"')) - expect(outline.indexOf('text "middle"')).toBeLessThan(outline.indexOf('text "after"')) - host.hidden = true - expect(outlineOf(collectSnapshot())).not.toContain('Before') - }) - - it('gives interactive headings actionable refs while preserving static headings', () => { - document.body.innerHTML = - '

Overview

' - for (const el of document.querySelectorAll('h3, h2')) visible(el) - const clicked = vi.fn() - document.querySelector('h3')?.addEventListener('click', clicked) - const outline = outlineOf(collectSnapshot()) - expect(outline).toContain('tab "Details"') - expect(outline).toContain('aria-expanded=false') - expect(outline).toContain('heading "Overview" (h2)') - expect(clickElement(refFor(outline, 'Details'))).toMatchObject({ dispatched: true }) - expect(clicked).toHaveBeenCalledOnce() - }) - - it('exposes structured input types and multiple-selection controls', () => { - document.body.innerHTML = - '' - for (const el of document.querySelectorAll('input, select')) visible(el) - const outline = outlineOf(collectSnapshot()) - expect(outline).toContain('type="date"') - expect(outline).toMatch(/combobox "Countries" \[ref=\d+\] multiple/) - }) - - it('retains sender and timestamp text omitted from a row accessibility label', () => { - document.body.innerHTML = ` -
- Sid Studio - Quarterly plan - 11:42 AM - -
- ` - visible(document.querySelector('[role="link"]') as HTMLDivElement) - for (const child of document.querySelectorAll('span')) visible(child) - - const outline = outlineOf(collectSnapshot()) - - expect(outline).toContain('link "Quarterly plan Updated forecast"') - expect(outline).toContain('text "Sid Studio"') - expect(outline).toContain('text "11:42 AM"') - expect(outline).toContain('text "Has attachment"') - expect(outline).not.toContain('text "Quarterly plan"') - }) - it('recovers a ref when React uniquely replaces the same logical element', () => { document.body.innerHTML = '' const original = visible(document.querySelector('button') as HTMLButtonElement) @@ -1171,24 +634,6 @@ describe('collectSnapshot', () => { expect(clicked).toBe(true) }) - it('recovers from a connected but collapsed node to its unique visible replacement', () => { - document.body.innerHTML = - '
' - const original = visible(document.querySelector('[role="combobox"]') as HTMLDivElement) - visible(document.querySelector('input') as HTMLInputElement) - const ref = refFor(outlineOf(collectSnapshot()), 'To:') - original.getBoundingClientRect = () => - ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 }) as DOMRect - const replacement = visible(original.cloneNode(true) as HTMLDivElement) - visible(replacement.querySelector('input') as HTMLInputElement) - document.body.append(replacement) - - expect(focusElementForTyping(ref)).toMatchObject({ - focused: true, - refRecovered: true, - }) - }) - it('does not guess between visible replacements for a collapsed connected ref', () => { document.body.innerHTML = '
' @@ -1206,40 +651,8 @@ describe('collectSnapshot', () => { expect(focusElementForTyping(ref)).toMatchObject({ error: 'stale' }) }) - it('keeps a connected ref usable after a same-document URL change', () => { - document.body.innerHTML = '' - const button = visible(document.querySelector('button') as HTMLButtonElement) - const ref = refFor(outlineOf(collectSnapshot()), 'Send') - let clicked = false - button.addEventListener('click', () => { - clicked = true - }) - - window.history.pushState({}, '', '/client/T123/C456') - - expect(clickElement(ref)).toMatchObject({ dispatched: true, refRecovered: false }) - expect(clicked).toBe(true) - }) - - it('recovers a replaced ref after a same-document URL change', () => { - document.body.innerHTML = '' - const original = visible(document.querySelector('button') as HTMLButtonElement) - const ref = refFor(outlineOf(collectSnapshot()), 'Messages') - const replacement = visible(original.cloneNode(true) as HTMLButtonElement) - let clicked = false - replacement.addEventListener('click', () => { - clicked = true - }) - - window.history.pushState({}, '', '/client/T123/C456') - original.replaceWith(replacement) - - expect(clickElement(ref)).toMatchObject({ dispatched: true, refRecovered: true }) - expect(clicked).toBe(true) - }) - - it('refuses to recover a ref when replacement is ambiguous', () => { - document.body.innerHTML = '' + it('refuses to recover a ref when replacement is ambiguous', () => { + document.body.innerHTML = '' const original = visible(document.querySelector('button') as HTMLButtonElement) const ref = refFor(outlineOf(collectSnapshot()), 'Close') const first = visible(original.cloneNode(true) as HTMLButtonElement) @@ -1260,20 +673,6 @@ describe('collectSnapshot', () => { expect(clickElement(ref)).toMatchObject({ error: 'stale' }) }) - it('invalidates a generic connected row action when its surrounding item is recycled', () => { - document.body.innerHTML = ` -
eng-bugs
- ` - for (const element of document.querySelectorAll('*')) visible(element as HTMLElement) - const button = document.querySelector('button') as HTMLButtonElement - const ref = refFor(outlineOf(collectSnapshot()), 'More actions') - - ;(document.querySelector('span') as HTMLSpanElement).textContent = 'random' - - expect(button.isConnected).toBe(true) - expect(clickElement(ref)).toMatchObject({ error: 'stale' }) - }) - it('never recycles numeric refs across snapshots', () => { document.body.innerHTML = '' visible(document.querySelector('button') as HTMLButtonElement) @@ -1284,129 +683,9 @@ describe('collectSnapshot', () => { expect(clickElement(firstRef)).toMatchObject({ error: 'stale' }) expect(clickElement(secondRef)).toMatchObject({ dispatched: true }) }) - - // The exact shape of the reported failure: hovering a Slack message mounts an - // action bar, but it is a role="toolbar"/"group" — none of the three roles the - // popup scan used to match. The hover therefore observed no popup change, no - // target change, and so no effect at all, and the agent concluded hovering - // did not work and fell back to clicking pixels off screenshots. - it('sees a row action bar that mounts on hover', () => { - document.body.innerHTML = '
Hello
' - visible(document.querySelector('[data-testid="message"]') as HTMLElement) - - const before = readPageActionState(true) as { popups: string[] } - expect(before.popups).toEqual([]) - - const toolbar = visible(document.createElement('div')) - toolbar.setAttribute('role', 'toolbar') - toolbar.setAttribute('aria-label', 'Message shortcuts') - document.body.append(toolbar) - - const after = readPageActionState(false) as { popups: string[] } - expect(after.popups).toEqual(['Message shortcuts']) - expect(after.popups).not.toEqual(before.popups) - }) - - it.each([ - { role: 'dialog', field: 'dialogs' }, - { role: 'toolbar', field: 'popups' }, - ] as const)( - 'reports truncation when visible $field exceed the summary limit', - ({ role, field }) => { - for (let index = 0; index < 10; index++) { - const element = visible(document.createElement('div')) - element.setAttribute('role', role) - element.setAttribute('aria-label', `Existing ${index}`) - document.body.append(element) - } - const before = readPageActionState() as { - dialogs: string[] - popups: string[] - observationTruncated: boolean - } - expect(before[field]).toHaveLength(10) - expect(before.observationTruncated).toBe(false) - - const additional = visible(document.createElement('div')) - additional.setAttribute('role', role === 'toolbar' ? 'listbox' : role) - additional.setAttribute('aria-label', 'New overlay') - document.body.append(additional) - expect(readPageActionState()).toMatchObject({ - [field]: before[field], - observationTruncated: true, - }) - - additional.setAttribute('aria-hidden', 'true') - expect(readPageActionState()).toMatchObject({ - [field]: before[field], - observationTruncated: false, - }) - } - ) - - it('reports a targeted control semantic disappearance after its panel closes', () => { - document.body.innerHTML = ` - - ` - const panel = document.querySelector('aside') as HTMLElement - visible(panel) - visible(document.querySelector('button') as HTMLButtonElement) - const ref = refFor(outlineOf(collectSnapshot()), 'Close thread') - - const before = readPageActionState(true, ref) as { - targetState: { present: boolean; rendered: boolean } - } - panel.remove() - const composer = visible(document.createElement('textarea')) - composer.setAttribute('aria-label', 'Message') - document.body.append(composer) - const after = readPageActionState(false, ref) as { - targetState: { present: boolean; rendered: boolean } - } - - expect(before.targetState).toMatchObject({ present: true, rendered: true }) - expect(after.targetState).toEqual({ present: false, rendered: false }) - }) - - it('keeps semantic target presence through a unique React replacement', () => { - document.body.innerHTML = - '' - const original = visible(document.querySelector('button') as HTMLButtonElement) - const ref = refFor(outlineOf(collectSnapshot()), 'Close thread') - const before = readPageActionState(true, ref) as { targetState: unknown } - const replacement = visible(original.cloneNode(true) as HTMLButtonElement) - original.replaceWith(replacement) - const after = readPageActionState(false, ref) as { targetState: unknown } - - expect(after.targetState).toEqual(before.targetState) - }) }) describe('semantic control state', () => { - it('scopes a fresh snapshot to one card without reading sibling geometry', () => { - document.body.innerHTML = - '
' - for (const element of document.querySelectorAll('*')) visible(element) - const full = outlineOf(collectSnapshot()) - const oldRootRef = refFor(full, 'Selected card') - const outside = document.querySelector('body > button')! - const outsideGeometry = vi.spyOn(outside, 'getBoundingClientRect') - - const scoped = runSerialized(collectSnapshot, [100, oldRootRef]) as { - scoped: boolean - outline: string - refIds: number[] - } - expect(scoped.scoped).toBe(true) - expect(scoped.outline).toContain('Selected card') - expect(scoped.outline).toContain('Save card') - expect(scoped.outline).not.toContain('Outside card') - expect(scoped.outline).not.toContain('private') - expect(scoped.refIds.every((id) => id >= 100)).toBe(true) - expect(window.__simAgentResolveElement?.(oldRootRef)).toBeNull() - expect(outsideGeometry).not.toHaveBeenCalled() - }) - it('rejects stale or framed snapshot roots without replacing the page registry', () => { const button = document.createElement('button') document.body.append(visible(button)) @@ -1423,39 +702,6 @@ describe('semantic control state', () => { expect(collectSnapshot(10, 0)).toEqual({ error: 'framed-snapshot' }) }) - it.each(['detached', 'hidden', 'renamed'])( - 'rejects a %s root before scoped capture without adopting a lookalike', - (change) => { - document.body.innerHTML = - '
' - for (const element of document.querySelectorAll('*')) visible(element) - const root = document.querySelector('#card')! - const rootRef = refFor(outlineOf(collectSnapshot()), 'Selected card') - const replacement = root.cloneNode(true) as HTMLElement - for (const element of [replacement, ...replacement.querySelectorAll('*')]) visible(element) - if (change === 'detached') root.replaceWith(replacement) - else { - root.after(replacement) - if (change === 'hidden') root.setAttribute('hidden', '') - else root.setAttribute('aria-label', 'Different card') - } - - expect(runSerialized(collectSnapshot, [100, rootRef])).toMatchObject({ error: 'stale' }) - expect(window.__simAgentElements?.[rootRef]).toBe(root) - expect(runSerialized(collectSnapshot, [100, rootRef])).toMatchObject({ error: 'stale' }) - } - ) - - it('marks unreadable scoped frame content truncated', () => { - const root = visible(document.createElement('div')) - const frame = visible(document.createElement('iframe')) - root.append(frame) - document.body.append(root) - register(root) - Object.defineProperty(frame, 'contentDocument', { value: null }) - expect(collectSnapshot(10, 0)).toMatchObject({ scoped: true, truncated: true }) - }) - it('does not recover scoped refs into a different card after the original closes', () => { document.body.innerHTML = '
' @@ -1471,98 +717,6 @@ describe('semantic control state', () => { expect(window.__simAgentStaleReason).toContain('scoped snapshot root') }) - it('keeps scoped snapshots bounded when a selected container is very large', () => { - const root = document.createElement('div') - root.tabIndex = 0 - root.setAttribute('aria-label', 'Large card') - document.body.append(visible(root)) - register(root) - for (let index = 0; index < 400; index++) { - const button = visible(document.createElement('button')) - button.textContent = `Action ${index}` - root.append(button) - } - const scoped = collectSnapshot(10, 0) as { refIds: number[]; truncated: boolean } - expect(scoped.refIds).toHaveLength(300) - expect(scoped.truncated).toBe(true) - }) - - it('distinguishes hidden registered nodes from detached nodes without action recovery', () => { - const button = visible(document.createElement('button')) - document.body.append(button) - register(button) - window.__simAgentResolveElement = vi.fn(() => null) - button.style.display = 'none' - - expect(readPageActionState(false, 0, 'registered')).toMatchObject({ - targetState: { present: true, rendered: false }, - }) - button.remove() - expect(readPageActionState(false, 0, 'registered')).toMatchObject({ - targetState: { present: false, rendered: false }, - }) - expect(window.__simAgentResolveElement).not.toHaveBeenCalled() - }) - - it('does not report a text input as an unchecked control', () => { - const input = visible(document.createElement('input')) - document.body.append(input) - register(input) - - expect(readPageActionState(false, 0, 'registered')).toMatchObject({ - targetState: { present: true, checked: undefined }, - }) - expect(readPageActionState(false, 1, 'registered')).toEqual({ error: 'stale' }) - }) - - it.each([true, false])( - 'reports native disclosure state as %s without inventing it on other elements', - (open) => { - document.body.innerHTML = - '
Details
' - const details = document.querySelector('details')! - const dialog = document.querySelector('dialog')! - details.open = open - dialog.open = open - const elements = [ - details, - document.querySelector('summary')!, - dialog, - document.querySelector('button')!, - ] - register(...elements.map(visible)) - for (let id = 0; id < elements.length; id++) { - expect(runSerialized(readPageActionState, [false, id, 'registered'])).toMatchObject({ - targetState: { open: id === 3 ? undefined : open }, - }) - } - } - ) - - it.each(['checkbox', 'radio'])('reads native %s state with XHTML tag casing', (type) => { - const input = visible(document.createElement('input')) - input.type = type - input.checked = true - Object.defineProperty(input, 'tagName', { value: 'input' }) - document.body.append(input) - register(input) - - expect(runSerialized(readPageActionState, [false, 0, 'registered'])).toMatchObject({ - targetState: { checked: true }, - }) - }) - - it('preserves native and ARIA mixed states instead of reporting unchecked', () => { - document.body.innerHTML = - '
' - const checkbox = visible(document.querySelector('input') as HTMLInputElement) - checkbox.indeterminate = true - register(checkbox, visible(document.querySelector('div') as HTMLDivElement)) - - expect(readCheckableElementState(0)).toMatchObject({ checked: 'mixed' }) - expect(readCheckableElementState(1)).toMatchObject({ checked: 'mixed' }) - }) - it('honors disabled fieldsets and ARIA-disabled ancestors', () => { document.body.innerHTML = '
' @@ -1575,48 +729,6 @@ describe('semantic control state', () => { expect(readCheckableElementState(1)).toMatchObject({ disabled: true }) }) - it.each([true, false])( - 'reads ARIA-disabled=%s across shadow boundaries for waits and checked state', - (disabled) => { - const host = visible(document.createElement('div')) - host.setAttribute('aria-disabled', String(disabled)) - const nestedHost = visible(document.createElement('div')) - host.attachShadow({ mode: 'open' }).append(nestedHost) - const checkbox = visible(document.createElement('input')) - checkbox.type = 'checkbox' - nestedHost.attachShadow({ mode: 'open' }).append(checkbox) - document.body.append(host) - register(checkbox) - - expect(runSerialized(readCheckableElementState, [0])).toMatchObject({ disabled }) - expect(runSerialized(readPageActionState, [false, 0, 'registered'])).toMatchObject({ - targetState: { disabled }, - }) - } - ) - - it('reads native and ARIA checkable controls without mutating them', () => { - document.body.innerHTML = ` - - - ` - const checkbox = visible(document.querySelector('input') as HTMLInputElement) - const toggle = visible(document.querySelector('button') as HTMLButtonElement) - register(checkbox, toggle) - - expect(readCheckableElementState(0)).toMatchObject({ - checked: true, - disabled: false, - kind: 'input:checkbox', - }) - expect(readCheckableElementState(1)).toMatchObject({ - checked: false, - disabled: true, - kind: 'role:switch', - }) - expect(checkbox.checked).toBe(true) - }) - it('returns a viewport-clamped element screenshot rectangle', () => { const button = visible(document.createElement('button')) button.scrollIntoView = vi.fn() @@ -1656,29 +768,6 @@ describe('semantic control state', () => { expect(runSerialized(getElementScreenshotRect, [ref])).toMatchObject({ error: 'stale' }) expect(window.__simAgentElements?.[ref]).toBe(button) }) - - it('rejects same-origin frame crops rather than using frame-local coordinates', () => { - const frame = document.createElement('iframe') - document.body.append(frame) - const button = frame.contentDocument!.createElement('button') - frame.contentDocument!.body.append(button) - register(visible(button)) - - expect(getElementScreenshotRect(0)).toEqual({ error: 'framed-screenshot' }) - expect(readPageActionState(false, 0, 'registered')).toEqual({ error: 'framed-wait' }) - }) - - it('does not scroll an offscreen element into view for a screenshot', () => { - const button = visible(document.createElement('button')) - button.scrollIntoView = vi.fn() - document.body.append(button) - button.getBoundingClientRect = () => - ({ left: 0, right: 20, top: 5000, bottom: 5020, width: 20, height: 20 }) as DOMRect - register(button) - - expect(getElementScreenshotRect(0)).toEqual({ error: 'not-visible' }) - expect(button.scrollIntoView).not.toHaveBeenCalled() - }) }) describe('scrollPage', () => { @@ -1753,115 +842,6 @@ describe('scrollPage', () => { expect(scroller.scrollTop).toBe(300) }) - it('uses viewport width for the default horizontal distance', () => { - const { scroller, child } = makeHorizontalScroller() - Object.defineProperty(scroller, 'scrollWidth', { configurable: true, value: 10_000 }) - register(child) - - expect(scrollPage('right', undefined, 0)).toMatchObject({ - movedBy: Math.round(window.innerWidth * 0.85), - }) - }) - - it('skips a vertical-only descendant when targeting a horizontal ancestor', () => { - const { scroller, child } = makeHorizontalScroller(200) - child.style.overflowY = 'auto' - Object.defineProperties(child, { - clientHeight: { configurable: true, value: 50 }, - scrollHeight: { configurable: true, value: 500 }, - scrollTop: { configurable: true, writable: true, value: 100 }, - }) - register(child) - - expect(scrollPage('left', 75, 0)).toMatchObject({ - target: 'Message history', - targetSource: 'element', - movedBy: -75, - scrollLeft: 125, - }) - expect(scroller.scrollTop).toBe(300) - expect(child.scrollTop).toBe(100) - }) - - it('keeps a centered horizontal pane at its boundary instead of scrolling another pane', () => { - const { scroller, child } = makeHorizontalScroller(800) - const other = visible(document.createElement('div')) - other.style.overflowX = 'auto' - other.setAttribute('aria-label', 'Unrelated pane') - Object.defineProperties(other, { - clientWidth: { configurable: true, value: 200 }, - scrollWidth: { configurable: true, value: 1_000 }, - }) - document.body.prepend(other) - Object.defineProperty(document, 'elementsFromPoint', { - configurable: true, - value: () => [child, scroller], - }) - - expect(scrollPage('right', 100)).toMatchObject({ - target: 'Message history', - targetSource: 'viewport-center-boundary', - movedBy: 0, - atRight: true, - }) - expect(other.scrollLeft).toBe(0) - }) - - it.each([ - { direction: 'left', before: 0, after: -100, movedBy: -100, atLeft: false, atRight: false }, - { direction: 'left', before: -750, after: -800, movedBy: -50, atLeft: true, atRight: false }, - { direction: 'left', before: -800, after: -800, movedBy: 0, atLeft: true, atRight: false }, - { direction: 'right', before: -50, after: 0, movedBy: 50, atLeft: false, atRight: true }, - { direction: 'right', before: 0, after: 0, movedBy: 0, atLeft: false, atRight: true }, - ])('scrolls RTL $direction from $before with physical boundaries', (test) => { - const { child } = makeHorizontalScroller(test.before, true) - register(child) - - expect(scrollPage(test.direction, 100, 0)).toMatchObject({ - scrollLeft: test.after, - movedBy: test.movedBy, - atLeft: test.atLeft, - atRight: test.atRight, - }) - }) - - it('scrolls the document root containing an explicit same-origin iframe ref', () => { - document.body.innerHTML = '' - const frame = visible(document.querySelector('iframe') as HTMLIFrameElement) - const frameDocument = frame.contentDocument as Document - const frameWindow = frame.contentWindow as Window - frameDocument.body.innerHTML = '
wide table
' - const child = visible(frameDocument.body.firstElementChild as HTMLDivElement) - const root = visible(frameDocument.documentElement) - Object.defineProperties(root, { - clientWidth: { configurable: true, value: 200 }, - scrollWidth: { configurable: true, value: 1_000 }, - scrollLeft: { configurable: true, writable: true, value: 0 }, - }) - Object.defineProperty(frameWindow, 'scrollX', { configurable: true, writable: true, value: 0 }) - Object.defineProperty(frameWindow, 'scrollBy', { - configurable: true, - value: ({ left }: ScrollToOptions) => { - root.scrollLeft = Math.max(0, Math.min(800, root.scrollLeft + (left || 0))) - Object.defineProperty(frameWindow, 'scrollX', { - configurable: true, - value: root.scrollLeft, - }) - }, - }) - register(child) - - expect(scrollPage('right', 100, 0)).toMatchObject({ - target: 'html', - targetSource: 'element', - scrollLeft: 100, - movedBy: 100, - atLeft: false, - atRight: false, - windowScrollX: 0, - }) - }) - it('scrolls the movable internal container under the viewport center', () => { const { scroller, child } = makeScroller(600) Object.defineProperty(document, 'elementsFromPoint', { @@ -1879,20 +859,6 @@ describe('scrollPage', () => { }) }) - it('targets the nearest scrollable ancestor of an explicit ref', () => { - const { scroller, child } = makeScroller(0) - const ref = refFor(outlineOf(collectSnapshot()), 'message') - - expect(scrollPage('down', 125, ref)).toMatchObject({ - target: 'Message history', - targetSource: 'element', - scrollTop: 125, - movedBy: 125, - }) - expect(child.textContent).toBe('message') - expect(scroller.scrollTop).toBe(125) - }) - it('walks past an immovable nearest scroller to a movable ancestor for an explicit ref', () => { document.body.innerHTML = `
@@ -1933,45 +899,6 @@ describe('scrollPage', () => { expect(outer.scrollTop).toBe(400) }) - it('skips an immovable focused sidebar for the movable centered history pane', () => { - document.body.innerHTML = ` - -
message
- ` - const sidebar = visible(document.querySelector('#sidebar') as HTMLDivElement) - const history = visible(document.querySelector('#history') as HTMLDivElement) - const message = visible(history.firstElementChild as HTMLDivElement) - for (const [element, scrollTop] of [ - [sidebar, 0], - [history, 600], - ] as const) { - Object.defineProperties(element, { - clientHeight: { configurable: true, value: 200 }, - scrollHeight: { configurable: true, value: 1_000 }, - scrollTop: { configurable: true, writable: true, value: scrollTop }, - }) - Object.defineProperty(element, 'scrollBy', { - configurable: true, - value: ({ top }: ScrollToOptions) => { - element.scrollTop = Math.max(0, Math.min(800, element.scrollTop + (top || 0))) - }, - }) - } - setActiveElement(document, sidebar) - Object.defineProperty(document, 'elementsFromPoint', { - configurable: true, - value: () => [message, history], - }) - - expect(scrollPage('up', 100)).toMatchObject({ - target: 'Message history', - targetSource: 'viewport-center', - movedBy: -100, - }) - expect(sidebar.scrollTop).toBe(0) - expect(history.scrollTop).toBe(500) - }) - it('keeps a centered pane at its boundary instead of scrolling another pane', () => { const { scroller: history, child: message } = makeScroller(800) const sidebar = visible(document.createElement('div')) @@ -2041,47 +968,6 @@ describe('readChildFrameElementState', () => { frameName: 'apps', }) }) - - it('hit-tests a frame against its shadow root instead of the outer document', () => { - const host = document.createElement('div') - document.body.append(host) - const shadow = host.attachShadow({ mode: 'open' }) - const frame = document.createElement('iframe') - frame.name = 'apps' - shadow.append(frame) - visible(frame) - Object.defineProperty(shadow, 'elementFromPoint', { - configurable: true, - value: () => frame, - }) - Object.defineProperty(document, 'elementFromPoint', { - configurable: true, - value: () => host, - }) - - expect(readChildFrameElementState('apps', '', '', 0)).toMatchObject({ - known: true, - visible: true, - frameName: 'apps', - }) - }) - - it('uses WindowProxy identity to distinguish duplicate frame metadata', () => { - document.body.innerHTML = ` - - - ` - const frames = Array.from(document.querySelectorAll('iframe')) as HTMLIFrameElement[] - frames.forEach(visible) - Object.defineProperty(document, 'elementFromPoint', { - configurable: true, - value: () => frames[1], - }) - - expect( - readChildFrameElementState('apps', 'https://example.com/widget', 'https://example.com', 1) - ).toMatchObject({ known: true, visible: true, frameName: 'apps' }) - }) }) describe('readActiveElementState', () => { @@ -2127,31 +1013,6 @@ describe('readActiveElementState', () => { redacted: true, }) }) - - it('reports ordinary fields in full', () => { - document.body.innerHTML = '' - setActiveElement(document, document.querySelector('input')) - - expect(readActiveElementState()).toMatchObject({ - activeElement: 'input', - valueLength: 5, - valuePreview: 'tokyo', - }) - }) - - it('descends into a same-origin frame rather than reporting the frame', () => { - const frame = document.createElement('iframe') - document.body.append(frame) - const inner = frame.contentDocument as Document - inner.body.innerHTML = '' - setActiveElement(inner, inner.querySelector('input')) - setActiveElement(document, frame) - - expect(readActiveElementState()).toMatchObject({ - activeElement: 'password-field', - redacted: true, - }) - }) }) describe('XHTML lower-case tagName', () => { @@ -2173,51 +1034,9 @@ describe('XHTML lower-case tagName', () => { expect(typeIntoElement(0, 'hunter2', false)).toEqual({ error: 'password' }) expect(input.value).toBe('') }) - - it('still withholds the value of a lower-case-tagName credential field', () => { - const input = lowerCaseTagInput( - '' - ) - visible(input) - - const outline = outlineOf(collectSnapshot()) - - expect(outline).not.toContain('hunter2') - }) }) describe('activeElementSecrecy', () => { - it('reports safe for an ordinary field', () => { - document.body.innerHTML = '' - setActiveElement(document, document.querySelector('input')) - - expect(activeElementSecrecy()).toBe('safe') - }) - - it('distinguishes a different focused element from an invalid target ref', () => { - document.body.innerHTML = ` - - - ` - const expected = visible(document.querySelectorAll('input')[0]) - const other = visible(document.querySelectorAll('input')[1]) - const snapshot = collectSnapshot() as { refIds: number[] } - const expectedRef = snapshot.refIds[0] - setActiveElement(document, other) - - expect(activeElementSecrecy(expectedRef)).toBe('different') - expect(activeElementSecrecy(Number.MAX_SAFE_INTEGER)).toBe('stale') - - setActiveElement(document, expected) - expect(activeElementSecrecy(expectedRef)).toBe('safe') - }) - - it('reports safe when nothing is focused', () => { - setActiveElement(document, document.body) - - expect(activeElementSecrecy()).toBe('safe') - }) - it('reports secret for a focused password field', () => { document.body.innerHTML = '' setActiveElement(document, document.querySelector('input')) @@ -2262,44 +1081,6 @@ describe('activeElementSecrecy', () => { expect(host.shadowRoot).toBeNull() expect(activeElementSecrecy()).toBe('opaque') }) - - it('reports opaque for a closed shadow root on a custom element', () => { - const host = document.createElement('my-login') - document.body.append(host) - host.attachShadow({ mode: 'closed' }).innerHTML = '' - setActiveElement(document, host) - - expect(activeElementSecrecy()).toBe('opaque') - }) - - it('still reports safe for a focused element that is focusable in its own right', () => { - // The false-positive guard: a div the page made focusable is focused - // itself, not hiding a shadow tree, so keystrokes are not refused. - document.body.innerHTML = '
menu
' - setActiveElement(document, document.querySelector('div')) - - expect(activeElementSecrecy()).toBe('safe') - }) - - it('still reports safe for a focused contenteditable', () => { - document.body.innerHTML = '
note
' - const editable = document.querySelector('div') as HTMLElement - Object.defineProperty(editable, 'isContentEditable', { get: () => true }) - setActiveElement(document, editable) - - expect(activeElementSecrecy()).toBe('safe') - }) - - it('descends into a same-origin frame instead of calling it opaque', () => { - const frame = document.createElement('iframe') - document.body.append(frame) - const inner = frame.contentDocument as Document - inner.body.innerHTML = '' - setActiveElement(inner, inner.querySelector('input')) - setActiveElement(document, frame) - - expect(activeElementSecrecy()).toBe('safe') - }) }) describe('pressKeyOnPage', () => { @@ -2311,19 +1092,6 @@ describe('pressKeyOnPage', () => { error: 'password', }) }) - - it('delivers keystrokes to ordinary fields', () => { - document.body.innerHTML = '' - const input = document.querySelector('input') as HTMLInputElement - setActiveElement(document, input) - const seen: string[] = [] - input.addEventListener('keydown', (event) => seen.push(event.key)) - - expect(pressKeyOnPage('a', 'KeyA', 65, false, false, false, false)).toMatchObject({ - pressed: 'a', - }) - expect(seen).toEqual(['a']) - }) }) describe('describePointTarget', () => { @@ -2334,20 +1102,6 @@ describe('describePointTarget', () => { }) } - it('describes the element at a viewport point', () => { - document.body.innerHTML = '' - pointAt(document.querySelector('button')) - - expect(describePointTarget(10, 10)).toMatchObject({ - found: true, - tag: 'button', - element: 'button "Send message"', - editable: false, - fileInput: false, - secret: false, - }) - }) - it('flags file inputs and password fields at the point', () => { document.body.innerHTML = '' pointAt(document.querySelector('input')) @@ -2361,78 +1115,14 @@ describe('describePointTarget', () => { editable: true, }) }) - - it('rejects points outside the viewport', () => { - expect(describePointTarget(-5, 10)).toEqual({ error: 'outside-viewport' }) - expect(describePointTarget(10, window.innerHeight + 5)).toEqual({ - error: 'outside-viewport', - }) - }) }) describe('describeFocusedEditable', () => { - it('reports no focus when the body holds focus', () => { - setActiveElement(document, document.body) - expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'none' }) - }) - - // The bug this pins: focus inside a same-origin frame surfaces on the outer - // document as the FRAME element, which is not an input, not contentEditable, - // not a canvas, and carries no textbox role — so a composer that press-key - // typed into fine was reported `not-editable` and insert_text refused. The - // descent here must match activeElementReadback's exactly. - it('descends a same-origin frame to the editable that really holds focus', () => { - document.body.innerHTML = '' - const frame = document.createElement('iframe') - document.body.append(frame) - const inner = frame.contentDocument as Document - inner.body.innerHTML = '
composer
' - const composer = inner.querySelector('div') as HTMLElement - Object.defineProperty(composer, 'isContentEditable', { value: true, configurable: true }) - setActiveElement(inner, composer) - setActiveElement(document, frame) - - expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'contenteditable' }) - }) - - it('names the focused element when it refuses, so the agent can recover', () => { - document.body.innerHTML = '
Send
' - setActiveElement(document, document.querySelector('div')) - - expect(describeFocusedEditable()).toEqual({ - editable: false, - reason: 'not-editable', - focusedTag: 'div', - focusedRole: 'button', - contentEditable: 'unset', - }) - }) - - it('reports a writable input as insertable', () => { - document.body.innerHTML = '' - setActiveElement(document, document.querySelector('input')) - expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'input:text' }) - }) - it('reports a read-only input as not insertable', () => { document.body.innerHTML = '' setActiveElement(document, document.querySelector('input')) expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'readonly' }) }) - - it('reports a focused contenteditable editor as insertable', () => { - document.body.innerHTML = '
' - const editor = document.querySelector('div') as HTMLElement - Object.defineProperty(editor, 'isContentEditable', { get: () => true }) - setActiveElement(document, editor) - expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'contenteditable' }) - }) - - it('treats a focused canvas editor surface as insertable', () => { - document.body.innerHTML = '' - setActiveElement(document, document.querySelector('canvas')) - expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'canvas' }) - }) }) describe('setFocusedInputValue', () => { @@ -2460,38 +1150,6 @@ describe('setFocusedInputValue', () => { }) } - it('accepts native datetime normalization and bypasses an overridden value setter', () => { - document.body.innerHTML = '' - const input = document.querySelector('input') as HTMLInputElement - register(input) - input.focus() - const setter = vi.fn() - Object.defineProperty(input, 'value', { - configurable: true, - get() { - return Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.get?.call(this) - }, - set: setter, - }) - expect(setFocusedInputValue(0, '2026-09-15T15:48:00')).toEqual({ dispatched: true }) - expect(input.value).toBe('2026-09-15T15:48') - expect(setter).not.toHaveBeenCalled() - }) - - it('does not write to a newly focused input inside a registered container', () => { - document.body.innerHTML = '
' - const container = visible(document.querySelector('div') as HTMLDivElement) - visible(document.querySelector('input') as HTMLInputElement) - register(container) - expect(focusElementForTyping(0)).toMatchObject({ valueInput: true }) - const other = document.createElement('input') - other.type = 'date' - container.append(other) - other.focus() - expect(setFocusedInputValue(0, '2026-09-15')).toHaveProperty('error') - expect(other.value).toBe('') - }) - it('rejects malformed values before changing the field or emitting events', () => { document.body.innerHTML = '' const input = document.querySelector('input') as HTMLInputElement @@ -2558,30 +1216,6 @@ describe('modal hidden together with its own app root', () => { ) }) - it('keeps a portaled modal scoped exactly as before', () => { - document.body.innerHTML = ` - -
` - showAll() - - const outline = outlineOf(collectSnapshot()) - - expect(outline).toContain('Send') - expect(outline).not.toContain('Compose') - }) - - it('keeps an aria-hidden region hidden when no modal is open', () => { - document.body.innerHTML = ` - - ` - showAll() - - const outline = outlineOf(collectSnapshot()) - - expect(outline).toContain('Shown action') - expect(outline).not.toContain('Hidden action') - }) - it('exposes only the topmost of stacked disablePortal modals', () => { document.body.innerHTML = ` - - ) - ) - - const homeControls = host?.querySelectorAll( - '[data-copy="home"] :is(button, input)' - ) - expect(homeControls).toHaveLength(2) - for (const control of homeControls ?? []) expect(control.tabIndex).toBe(0) - - const cloneControls = host?.querySelectorAll( - ':is([data-copy="lead"], [data-copy="tail"]) :is(button, input)' - ) - expect(cloneControls).toHaveLength(4) - for (const control of cloneControls ?? []) expect(control.tabIndex).toBe(-1) - - const cloneButton = host?.querySelector('[data-copy="tail"] button') - expect(cloneButton?.disabled).toBe(false) - act(() => cloneButton?.click()) - expect(activations).toBe(1) - }) - - it('drags with the mouse, scrolling by the pointer delta and swallowing the click', () => { - const rail = mount() - const link = rail.querySelector('[data-copy="home"] a') - if (!link) throw new Error('no home link') - /** jsdom has no PointerEvent; a MouseEvent carrying the pointer fields is what the handlers read. */ - const pointer = (type: string, clientX: number) => { - const event = new MouseEvent(type, { bubbles: true, button: 0, clientX }) - Object.defineProperties(event, { pointerType: { value: 'mouse' }, pointerId: { value: 1 } }) - return event - } - - rail.scrollLeft = SET - link.dispatchEvent(pointer('pointerdown', 300)) - link.dispatchEvent(pointer('pointermove', 303)) - expect(rail.scrollLeft).toBe(SET) - link.dispatchEvent(pointer('pointermove', 260)) - expect(rail.scrollLeft).toBe(SET + 40) - expect(rail.dataset.dragging).toBe('') - link.dispatchEvent(pointer('pointerup', 260)) - expect(rail.dataset.dragging).toBeUndefined() - - const click = new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 }) - link.dispatchEvent(click) - expect(click.defaultPrevented).toBe(true) - - let swallowed: boolean | null = null - link.addEventListener( - 'click', - (event) => { - swallowed = event.defaultPrevented - event.preventDefault() - }, - { once: true } - ) - link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) - expect(swallowed).toBe(false) - }) - - it.each(['pointercancel', 'pointerup'])( - 'allows keyboard activation after a drag ends with %s', - (endEvent) => { - const rail = mount() - const link = rail.querySelector('[data-copy="home"] a') - if (!link) throw new Error('no home link') - for (const [type, clientX] of [ - ['pointerdown', 300], - ['pointermove', 260], - [endEvent, 260], - ] as const) { - const event = new MouseEvent(type, { bubbles: true, button: 0, clientX }) - Object.defineProperties(event, { pointerType: { value: 'mouse' }, pointerId: { value: 1 } }) - link.dispatchEvent(event) - } - expect(rail.dataset.dragging).toBeUndefined() - - let activated = false - link.addEventListener('click', (event) => { - activated = true - event.preventDefault() - }) - link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, detail: 0 })) - expect(activated).toBe(true) - } - ) - - it('folds the position back into the middle copy as the user scrolls past it', () => { - const rail = mount() - - expect(scrollTo(rail, SET + 200)).toBe(SET + 200) - expect(scrollTo(rail, SET * 1.5 + 100)).toBe(SET * 0.5 + 100) - expect(scrollTo(rail, SET * 0.5 - 60)).toBe(SET * 1.5 - 60) - }) -}) diff --git a/apps/sim/app/(landing)/components/features/features.test.tsx b/apps/sim/app/(landing)/components/features/features.test.tsx deleted file mode 100644 index 29d3ae24ad2..00000000000 --- a/apps/sim/app/(landing)/components/features/features.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @vitest-environment node - */ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { Features } from '@/app/(landing)/components/features/features' - -const MODULES = [ - { title: 'CLI', href: 'https://docs.sim.ai/cli' }, - { title: 'Workflows', href: '/workflows' }, - { title: 'Knowledge Base', href: '/knowledge' }, - { title: 'Tables', href: '/tables' }, - { title: 'Files', href: '/files' }, - { title: 'Logs', href: '/logs' }, -] as const - -describe('Features', () => { - it('renders every core Sim module as a crawlable tall card', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Everything AI agents need to do real work') - expect(html).toContain('aspect-[5/6]') - expect(html).toContain('rounded-[12px]') - expect(html).toContain('overflow-x-auto') - - for (const module of MODULES) { - expect(html).toContain(`>${module.title}`) - expect(html).toContain(`href="${module.href}"`) - } - }) -}) diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx deleted file mode 100644 index e54d3302f51..00000000000 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { WORDMARK_PATHS } from '@sim/emcn' -import { createRoot, type Root } from 'react-dom/client' -import { renderToStaticMarkup } from 'react-dom/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { FooterWordmarkLoop } from '@/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop' - -const SHAPES = ['metaballs', 'relay', 'compass', 'corners', 'burst', 'squeeze', 'thinking'] as const - -/** One full pass of the master film, in ms. */ -const CYCLE_MS = 17_100 - -let pending: FrameRequestCallback[] = [] -let clock = 0 -let reducedMotion = false -let onMotionPreference: (() => void) | undefined -let root: Root | null = null -let host: HTMLDivElement | null = null - -/** - * Drives the captured frame callbacks to `ms` on the loop's own clock in 50ms - * steps - under the loop's 100ms per-frame cap, so no choreography is skipped. - */ -function advanceTo(ms: number): void { - while (clock < ms) { - clock = Math.min(clock + 50, ms) - const frame = pending.shift() - if (!frame) throw new Error('the loop stopped requesting frames') - const now = clock - act(() => frame(now)) - } -} - -function attr(selector: string, name: string): string | null { - return host?.querySelector(selector)?.getAttribute(name) ?? null -} - -beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - pending = [] - clock = 0 - reducedMotion = false - onMotionPreference = undefined - const stubs = { - requestAnimationFrame: (cb: FrameRequestCallback) => pending.push(cb), - cancelAnimationFrame: () => { - pending = [] - }, - matchMedia: () => ({ - get matches() { - return reducedMotion - }, - addEventListener: (_type: string, listener: () => void) => { - onMotionPreference = listener - }, - removeEventListener: () => { - onMotionPreference = undefined - }, - }), - } - for (const [name, value] of Object.entries(stubs)) { - vi.stubGlobal(name, value) - Object.assign(window, { [name]: value }) - } - host = document.createElement('div') - document.body.append(host) - root = createRoot(host) - act(() => root?.render()) - const anchor = pending.shift() - if (!anchor) throw new Error('the loop never started') - act(() => anchor(0)) -}) - -afterEach(() => { - act(() => root?.unmount()) - root = null - host?.remove() - host = null - vi.unstubAllGlobals() -}) - -describe('FooterWordmarkLoop', () => { - it('server-renders the crisp wordmark as the resting frame', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('aria-hidden="true"') - expect(html).toContain('data-stage="wm" opacity="1"') - expect(html).toContain('data-stage="orb" opacity="0"') - expect(html).toContain('stdDeviation="0"') - expect(html).toMatch(/filter="url\(#fwl-goo-/) - expect(html).toContain('values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"') - expect(html).not.toContain(' { - expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') - expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) - - advanceTo(2700) - expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') - expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') - expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) - - advanceTo(3900) - expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000') - expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000') - expect(attr('[data-anim="metaballsA"]', 'transform')).not.toBe('translate(0.000 0.000)') - - advanceTo(7000) - expect(attr('[data-stage="compass"]', 'opacity')).toBe('1.0000') - expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('0.0000') - - advanceTo(9700) - expect(attr('[data-stage="burst"]', 'opacity')).toBe('1.0000') - - advanceTo(16000) - expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') - expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') - expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) - - advanceTo(CYCLE_MS + 2700) - expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') - expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') - }) - - it('returns to an identity filter when reduced motion is enabled mid-morph', () => { - advanceTo(2700) - expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) - - reducedMotion = true - act(() => onMotionPreference?.()) - - expect(pending).toHaveLength(0) - expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') - expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000') - expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') - }) - - it('eases the same filter to identity at both wordmark boundaries', () => { - advanceTo(1300) - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') - expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) - - advanceTo(1301) - expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001) - expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) - - advanceTo(1800) - expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) - - advanceTo(2500) - expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') - - advanceTo(15199) - expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001) - expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) - - advanceTo(15200) - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') - expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) - expect(host?.querySelector('feComposite')).toBeNull() - }) - - it('stops requesting frames on unmount', () => { - advanceTo(500) - act(() => root?.unmount()) - root = null - expect(pending).toHaveLength(0) - }) -}) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-announcement-chip/hero-announcement-chip.test.tsx b/apps/sim/app/(landing)/components/hero/components/hero-announcement-chip/hero-announcement-chip.test.tsx deleted file mode 100644 index 87f01b75722..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/hero-announcement-chip/hero-announcement-chip.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @vitest-environment node - */ -import type { ComponentType, ReactNode } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', () => ({ - cn: (...values: Array) => values.filter(Boolean).join(' '), - ChipLink: ({ - children, - href, - leftAdornment, - rightIcon: RightIcon, - variant, - }: { - children: ReactNode - href: string - leftAdornment?: ReactNode - rightIcon?: ComponentType - variant?: string - }) => ( - - {leftAdornment} - {children} - {RightIcon ? : null} - - ), - ChipTag: ({ children, variant }: { children: ReactNode; variant?: string }) => ( - {children} - ), -})) - -vi.mock('@/app/(landing)/components/chevron-arrow', () => ({ - ChevronArrow: () => , -})) - -import { HeroAnnouncementChip } from '@/app/(landing)/components/hero/components/hero-announcement-chip/hero-announcement-chip' - -describe('HeroAnnouncementChip', () => { - it('announces GPT-6 Astra and links to Start building', () => { - const markup = renderToStaticMarkup() - - expect(markup).toContain('data-variant="outline"') - expect(markup).toContain('data-tag-variant="gray"') - expect(markup).toContain('href="/signup"') - expect(markup).toContain('New') - expect(markup).toContain('Use GPT-6 Astra') - expect(markup).toContain('Now available') - expect(markup).toContain('data-chevron-arrow') - }) -}) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.test.tsx b/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.test.tsx deleted file mode 100644 index a4c13692555..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop.test.tsx +++ /dev/null @@ -1,216 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - HeroChatLoop, - type HeroChatPhase, -} from '@/app/(landing)/components/hero/components/hero-chat-loop/hero-chat-loop' -import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components' -import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' - -interface AgentGroupProps { - agentName: string - agentLabel: string - items: AgentGroupItem[] - isStreaming?: boolean - isLaneOpen?: boolean -} - -const { renderAgentGroup, renderPendingIndicator } = vi.hoisted(() => ({ - renderAgentGroup: vi.fn<(props: AgentGroupProps) => void>(), - renderPendingIndicator: vi.fn<(props: { label: string }) => void>(), -})) - -vi.mock('@sim/emcn', () => ({ - cn: (...values: Array) => values.filter(Boolean).join(' '), - Button: ({ children }: { children: ReactNode }) => , - Tooltip: { - Root: ({ children }: { children: ReactNode }) => children, - Trigger: ({ children }: { children: ReactNode }) => children, - Content: () => null, - }, -})) -vi.mock('@sim/emcn/icons', () => ({ - Workflow: () => null, - Mic: () => null, - Paperclip: () => null, - Plus: () => null, - Slash: () => null, - X: () => null, -})) -vi.mock('@/app/(landing)/components/hero/components/hero-chat-welcome', () => ({ - HeroChatWelcome: () => null, -})) -vi.mock('@/app/(landing)/components/hero/components/hero-platform-loop/sidebar-hotspots', () => ({ - HERO_TOOLTIP_OFFSET: 8, -})) -vi.mock( - '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view', - () => ({ - AgentGroupView: (props: AgentGroupProps) => { - renderAgentGroup(props) - return
{props.agentLabel}
- }, - }) -) -vi.mock('@/app/(landing)/components/hero/components/hero-chat-loop/hero-tool-call-item', () => ({ - HeroToolCallItem: () => null, -})) -vi.mock( - '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator', - () => ({ - PendingTagIndicator: (props: { label: string }) => { - renderPendingIndicator(props) - return {props.label} - }, - }) -) -vi.mock( - '@/app/workspace/[workspaceId]/home/components/message-content/components/question', - () => ({ - parseQuestionAnswerMessage: () => undefined, - QuestionDisplay: () =>

What would you like to do next?

, - }) -) -vi.mock( - '@/app/workspace/[workspaceId]/home/components/user-input/components/send-button/send-button', - () => ({ SendButton: () => null }) -) - -let host: HTMLDivElement -let root: Root - -beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - vi.useFakeTimers() - vi.clearAllMocks() - vi.stubGlobal('matchMedia', () => ({ - matches: true, - addEventListener: () => {}, - removeEventListener: () => {}, - })) - host = document.createElement('div') - document.body.append(host) - root = createRoot(host) -}) - -afterEach(() => { - act(() => root.unmount()) - host.remove() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -function renderPhase(phase: HeroChatPhase) { - act(() => { - root.render( - - ) - }) -} - -function hasExecutingTool(items: AgentGroupItem[]): boolean { - return items.some((item) => item.type === 'tool' && item.data.status === ToolCallStatus.executing) -} - -describe('HeroChatLoop production thinking handoff', () => { - it('shows the submitted prompt before assistant activity begins', () => { - renderPhase('user') - - expect(host.textContent).toContain('Enrich new leads and post to #sales.') - expect(host.querySelector('output')).toBeNull() - expect(renderAgentGroup).not.toHaveBeenCalled() - expect(renderPendingIndicator).not.toHaveBeenCalled() - }) - - it.each([ - ['thinking', 'Thinking'], - ['dispatching', 'Dispatching…'], - ] as const)('shows the production activity indicator alone during %s', (phase, label) => { - renderPhase(phase) - - expect(host.querySelector('[data-chat-phase]')?.getAttribute('data-chat-phase')).toBe(phase) - expect(host.textContent).toContain('Enrich new leads and post to #sales.') - expect(host.querySelector('output')?.textContent).toBe(label) - expect(renderPendingIndicator).toHaveBeenLastCalledWith({ label }) - expect(renderAgentGroup).not.toHaveBeenCalled() - expect(host.textContent).not.toContain('The workflow is ready') - }) - - it('keeps the activity indicator mounted while thinking hands off to dispatching', () => { - renderPhase('thinking') - const indicator = host.querySelector('output') - expect(indicator).not.toBeNull() - - renderPhase('dispatching') - - expect(host.querySelector('output')).toBe(indicator) - expect(indicator?.textContent).toBe('Dispatching…') - expect(renderAgentGroup).not.toHaveBeenCalled() - }) - - it('replaces the turn loader with Workflow Agent activity only once building begins', () => { - renderPhase('thinking') - renderPhase('dispatching') - renderPhase('building') - - expect(host.querySelector('output')).toBeNull() - expect(host.querySelectorAll('section')).toHaveLength(1) - const props = renderAgentGroup.mock.lastCall?.[0] - expect(props).toMatchObject({ - agentName: 'workflow', - agentLabel: 'Workflow Agent', - isStreaming: true, - isLaneOpen: true, - }) - expect(props && hasExecutingTool(props.items)).toBe(true) - expect(host.textContent).not.toContain('The workflow is ready') - }) - - it('shows completed agent groups and the response after building finishes', () => { - renderPhase('reply') - - expect(host.querySelector('output')).toBeNull() - expect(Array.from(host.querySelectorAll('section'), (group) => group.textContent)).toEqual([ - 'Workflow Agent', - 'Sim', - ]) - const groups = renderAgentGroup.mock.calls.map(([props]) => props) - expect(groups.every((group) => !group.isStreaming && !hasExecutingTool(group.items))).toBe(true) - expect(host.textContent).toContain('The workflow is ready to test with a sample lead.') - expect(host.textContent).toContain('What would you like to do next?') - }) - it('reveals the reply before showing follow-up actions when motion is enabled', () => { - vi.stubGlobal('matchMedia', () => ({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - })) - renderPhase('reply') - expect(host.textContent).not.toContain('The workflow is ready') - expect(host.textContent).not.toContain('What would you like to do next?') - - act(() => vi.advanceTimersByTime(110)) - expect(host.textContent).toContain('The workflow') - expect(host.textContent).not.toContain('The workflow is ready to test with a sample lead.') - - act(() => vi.advanceTimersByTime(1000)) - expect(host.textContent).toContain('The workflow is ready to test with a sample lead.') - expect(host.textContent).toContain('What would you like to do next?') - }) -}) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-intro/hero-platform-intro.test.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-intro/hero-platform-intro.test.tsx deleted file mode 100644 index b3709462f40..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-intro/hero-platform-intro.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { HeroPlatformIntro } from '@/app/(landing)/components/hero/components/hero-platform-intro/hero-platform-intro' - -let root: Root -let host: HTMLDivElement -let enterViewport: () => void -let motionChange: () => void -let reducedMotion: boolean -const onComplete = vi.fn() -const onClick = vi.fn() -const disconnect = vi.fn() -const removeMotionListener = vi.fn() - -beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - reducedMotion = false - vi.clearAllMocks() - vi.stubGlobal('matchMedia', () => ({ - get matches() { - return reducedMotion - }, - addEventListener: (_event: string, callback: () => void) => { - motionChange = callback - }, - removeEventListener: removeMotionListener, - })) - vi.stubGlobal( - 'IntersectionObserver', - class { - constructor(callback: IntersectionObserverCallback) { - enterViewport = () => - callback( - [{ isIntersecting: true } as IntersectionObserverEntry], - this as IntersectionObserver - ) - } - observe = vi.fn() - disconnect = disconnect - } - ) - host = document.createElement('div') - document.body.append(host) - root = createRoot(host) -}) - -afterEach(() => { - act(() => root.unmount()) - host.remove() - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -function mount() { - act(() => { - root.render( - - - - ) - }) -} - -describe('HeroPlatformIntro', () => { - it('exposes usable content immediately and starts the exchange only once visible', () => { - mount() - expect(host.querySelector('[inert], [aria-hidden="true"], .opacity-0, svg')).toBeNull() - act(() => host.querySelector('button')?.click()) - expect(onClick).toHaveBeenCalledOnce() - expect(onComplete).not.toHaveBeenCalled() - - act(() => enterViewport()) - expect(onComplete).toHaveBeenCalledExactlyOnceWith(false) - expect(disconnect).toHaveBeenCalled() - act(() => enterViewport()) - expect(onComplete).toHaveBeenCalledOnce() - }) - - it.each(['pointerdown', 'focusin'])('preserves visitor control after early %s', (eventName) => { - mount() - act(() => host.querySelector('button')?.dispatchEvent(new Event(eventName, { bubbles: true }))) - expect(disconnect).toHaveBeenCalled() - act(() => enterViewport()) - act(() => { - reducedMotion = true - motionChange() - }) - expect(onComplete).not.toHaveBeenCalled() - }) - - it('does not wait for the painting to decode', () => { - const stage = document.createElement('div') - stage.dataset.previewStage = '' - host.before(stage) - stage.append(host) - const background = document.createElement('img') - background.dataset.previewBackground = '' - Object.defineProperty(background, 'complete', { value: false }) - background.decode = vi.fn() - stage.append(background) - - mount() - act(() => enterViewport()) - expect(background.decode).not.toHaveBeenCalled() - expect(onComplete).toHaveBeenCalledExactlyOnceWith(false) - stage.before(host) - stage.remove() - }) - - it('shows the completed preview immediately with reduced motion', () => { - reducedMotion = true - mount() - expect(onComplete).toHaveBeenCalledExactlyOnceWith(true) - }) - - it('settles when reduced motion is enabled before the preview enters view', () => { - mount() - act(() => { - reducedMotion = true - motionChange() - }) - act(() => enterViewport()) - expect(onComplete).toHaveBeenCalledExactlyOnceWith(true) - }) - - it('disconnects on unmount without starting the exchange from a queued callback', () => { - mount() - act(() => root.unmount()) - act(() => enterViewport()) - expect(onComplete).not.toHaveBeenCalled() - expect(disconnect).toHaveBeenCalled() - expect(removeMotionListener).toHaveBeenCalledWith('change', motionChange) - }) - - it('starts without waiting when IntersectionObserver is unavailable', () => { - vi.stubGlobal('IntersectionObserver', undefined) - mount() - expect(onComplete).toHaveBeenCalledExactlyOnceWith(false) - }) -}) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop-mount.test.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop-mount.test.tsx deleted file mode 100644 index a3148ada9fa..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop-mount.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { renderToStaticMarkup } from 'react-dom/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { HeroPlatformLoopMount } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop-mount' - -vi.mock('next/dynamic', () => ({ - default: () => () =>
Desktop demo
, -})) - -let host: HTMLDivElement -let root: Root -let desktopMedia: EventTarget & { matches: boolean } - -beforeEach(() => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - desktopMedia = Object.assign(new EventTarget(), { matches: false }) - vi.stubGlobal( - 'matchMedia', - vi.fn(() => desktopMedia) - ) - host = document.createElement('div') - document.body.append(host) - root = createRoot(host) -}) - -afterEach(() => { - act(() => root.unmount()) - host.remove() - vi.unstubAllGlobals() -}) - -describe('HeroPlatformLoopMount', () => { - it('leaves the desktop demo unmounted on mobile and responds to breakpoint changes', () => { - act(() => root.render()) - expect(host.textContent).toBe('') - - act(() => { - desktopMedia.matches = true - desktopMedia.dispatchEvent(new Event('change')) - }) - expect(host.textContent).toBe('Desktop demo') - - act(() => { - desktopMedia.matches = false - desktopMedia.dispatchEvent(new Event('change')) - }) - expect(host.textContent).toBe('') - }) - - it('keeps the server render empty even when the client viewport is desktop', () => { - desktopMedia.matches = true - expect(renderToStaticMarkup()).toBe('') - expect(window.matchMedia).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.test.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.test.tsx deleted file mode 100644 index 64c296187a5..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop.test.tsx +++ /dev/null @@ -1,324 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { HeroPlatformLoop } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-platform-loop' -import { DEFAULT_USER_MESSAGE } from '@/app/(landing)/components/hero/components/hero-platform-loop/preview-chat-content' - -interface ChatProps { - phase: string - composerValue: string - userMessage: string - showWelcome: boolean - isSending: boolean - onStopGeneration: () => void - onComposerFocus: () => void - onComposerValueChange: (value: string) => void - onSubmit: () => void -} - -let chat: ChatProps -let completeIntro: (reducedMotion: boolean) => void -let builtCount: number -let runWorkflow: () => void - -vi.mock('@sim/emcn', () => ({ - cn: (...values: Array) => values.filter(Boolean).join(' '), - Button: ({ children }: { children: ReactNode }) => , - Tooltip: { - Root: ({ children }: { children: ReactNode }) => children, - Trigger: ({ children }: { children: ReactNode }) => children, - Content: () => null, - }, -})) -vi.mock('@sim/emcn/icons', () => ({ PanelLeft: () => null })) -vi.mock('@/hooks/use-drag-resize', () => ({ - useDragResize: () => ({ handlePointerDown: vi.fn() }), -})) -vi.mock('@/app/(landing)/components/shared/hero-loop-shell', () => ({ - HeroLoopShell: ({ children }: { children: ReactNode }) => children, -})) -vi.mock('@/app/(landing)/components/hero/components/hero-platform-intro', () => ({ - HeroPlatformIntro: ({ - children, - onComplete, - }: { - children: ReactNode - onComplete: typeof completeIntro - }) => { - completeIntro = onComplete - return children - }, -})) -vi.mock('@/app/(landing)/components/hero/components/hero-chat-loop', () => ({ - HeroChatLoop: (props: ChatProps) => { - chat = props - return null - }, -})) -vi.mock( - '@/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel', - () => ({ - HeroResourcePanel: (props: { builtCount: number; onRunWorkflow: () => void }) => { - builtCount = props.builtCount - runWorkflow = props.onRunWorkflow - return null - }, - }) -) -vi.mock('@/app/(landing)/components/hero/components/hero-platform-loop/stage-data', () => ({ - STAGE_BLOCKS: Array.from({ length: 6 }, (_, id) => ({ id })), -})) - -let root: Root -let host: HTMLDivElement -let reducedMotion: boolean -let motionListeners: Set<() => void> - -beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - vi.useFakeTimers({ - toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'performance'], - }) - reducedMotion = false - motionListeners = new Set() - vi.stubGlobal('matchMedia', () => ({ - get matches() { - return reducedMotion - }, - addEventListener: (_event: string, callback: () => void) => motionListeners.add(callback), - removeEventListener: (_event: string, callback: () => void) => motionListeners.delete(callback), - })) - host = document.createElement('div') - document.body.append(host) - root = createRoot(host) - act(() => root.render()) -}) - -afterEach(() => { - act(() => root.unmount()) - host.remove() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -function advance(ms: number) { - act(() => vi.advanceTimersByTime(ms)) -} - -function panel() { - return host.querySelector('[data-resource-open]') -} - -describe('HeroPlatformLoop opening', () => { - it('thinks and dispatches before showing agent activity, opening the workflow, and building it', () => { - expect(chat.phase).toBe('idle') - expect(chat.showWelcome).toBe(true) - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - expect(panel()?.hasAttribute('inert')).toBe(true) - advance(10_000) - expect(chat.composerValue).toBe('') - - act(() => completeIntro(false)) - advance(1_000) - expect(chat.phase).toBe('compose') - expect(chat.composerValue.length).toBeGreaterThan(0) - expect(DEFAULT_USER_MESSAGE.startsWith(chat.composerValue)).toBe(true) - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - - advance(3_000) - expect(chat.phase).toBe('user') - expect(chat.composerValue).toBe('') - expect(chat.userMessage).toBe(DEFAULT_USER_MESSAGE) - advance(319) - expect(chat.phase).toBe('user') - advance(1) - expect(chat.phase).toBe('thinking') - expect(chat.isSending).toBe(true) - advance(3_549) - expect(chat.phase).toBe('thinking') - expect(builtCount).toBe(0) - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - advance(1) - expect(chat.phase).toBe('dispatching') - advance(650) - expect(chat.phase).toBe('building') - expect(chat.isSending).toBe(true) - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - advance(600) - expect(panel()?.getAttribute('data-resource-open')).toBe('true') - expect(panel()?.hasAttribute('inert')).toBe(false) - expect(builtCount).toBe(0) - advance(550) - expect(builtCount).toBe(1) - advance(4_000) - expect(builtCount).toBe(6) - expect(chat.phase).toBe('reply') - expect(chat.isSending).toBe(false) - }) - - it('lets a visitor take over typing and submit their own prompt', () => { - act(() => completeIntro(false)) - advance(1_000) - act(() => chat.onComposerFocus()) - const pausedPrompt = chat.composerValue - advance(10_000) - expect(chat.phase).toBe('idle') - expect(chat.composerValue).toBe(pausedPrompt) - - act(() => chat.onComposerValueChange('Build a pipeline report')) - advance(10_000) - expect(chat.composerValue).toBe('Build a pipeline report') - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - act(() => chat.onSubmit()) - expect(chat.phase).toBe('user') - expect(chat.userMessage).toBe('Build a pipeline report') - advance(1_200) - expect(chat.phase).toBe('thinking') - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - advance(3_920) - expect(panel()?.getAttribute('data-resource-open')).toBe('true') - }) - - it('cancels automatic typing when a suggested prompt replaces the composer value', () => { - act(() => completeIntro(false)) - advance(1_000) - act(() => chat.onComposerValueChange('Post deal alerts to #sales')) - advance(10_000) - expect(chat.phase).toBe('idle') - expect(chat.composerValue).toBe('Post deal alerts to #sales') - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - }) - - it('shows the finished preview without typing when the entrance reports reduced motion', () => { - act(() => completeIntro(true)) - expect(chat.phase).toBe('reply') - expect(chat.composerValue).toBe('') - expect(builtCount).toBe(6) - expect(panel()?.getAttribute('data-resource-open')).toBe('true') - expect(vi.getTimerCount()).toBe(0) - }) - - it('settles the preview when reduced motion is enabled during typing', () => { - act(() => completeIntro(false)) - advance(1_000) - act(() => { - reducedMotion = true - motionListeners.forEach((listener) => listener()) - }) - expect(chat.phase).toBe('reply') - expect(chat.composerValue).toBe('') - expect(builtCount).toBe(6) - expect(panel()?.getAttribute('data-resource-open')).toBe('true') - expect(vi.getTimerCount()).toBe(0) - expect(motionListeners.size).toBe(0) - }) - - it('cancels the pending thinking and build sequence when generation is stopped', () => { - act(() => chat.onComposerValueChange('Build a pipeline report')) - act(() => chat.onSubmit()) - advance(1_000) - expect(chat.phase).toBe('thinking') - act(() => chat.onStopGeneration()) - expect(chat.isSending).toBe(false) - expect(vi.getTimerCount()).toBe(0) - advance(10_000) - expect(chat.phase).toBe('reply') - expect(builtCount).toBe(6) - }) - - it('restarts thinking for a new submission without an old timer opening the workflow', () => { - act(() => chat.onComposerValueChange('Build a report')) - act(() => chat.onSubmit()) - advance(1_000) - act(() => chat.onComposerValueChange('Enrich new leads')) - act(() => chat.onSubmit()) - advance(3_000) - expect(chat.userMessage).toBe('Enrich new leads') - expect(chat.phase).toBe('thinking') - expect(builtCount).toBe(0) - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - advance(1_520) - expect(chat.phase).toBe('building') - }) - - it('settles and clears delayed work when reduced motion is enabled during thinking', () => { - act(() => chat.onComposerValueChange('Build a report')) - act(() => chat.onSubmit()) - advance(1_000) - act(() => { - reducedMotion = true - motionListeners.forEach((listener) => listener()) - }) - expect(chat.phase).toBe('reply') - expect(chat.isSending).toBe(false) - expect(builtCount).toBe(6) - expect(panel()?.getAttribute('data-resource-open')).toBe('true') - expect(vi.getTimerCount()).toBe(0) - }) - - it('cleans up automatic typing and motion listeners on unmount', () => { - act(() => completeIntro(false)) - advance(1_000) - act(() => root.unmount()) - expect(vi.getTimerCount()).toBe(0) - expect(motionListeners.size).toBe(0) - }) - - it('cancels a pending rerun before thinking through a new submission', () => { - act(() => completeIntro(true)) - act(() => runWorkflow()) - advance(400) - expect(builtCount).toBe(2) - act(() => chat.onComposerValueChange('Build a new report')) - act(() => chat.onSubmit()) - advance(2_000) - expect(chat.phase).toBe('thinking') - expect(builtCount).toBe(0) - expect(panel()?.getAttribute('data-resource-open')).toBe('false') - }) - - it('cancels pending chat build timers when the visitor runs the workflow', () => { - act(() => chat.onComposerValueChange('Build a report')) - act(() => chat.onSubmit()) - advance(1_000) - act(() => runWorkflow()) - advance(5_000) - expect(chat.phase).toBe('reply') - expect(chat.isSending).toBe(false) - expect(builtCount).toBe(6) - }) - - it('cancels a pending rerun when generation is stopped', () => { - act(() => completeIntro(true)) - act(() => runWorkflow()) - advance(400) - act(() => chat.onStopGeneration()) - expect(vi.getTimerCount()).toBe(0) - advance(1_000) - expect(builtCount).toBe(6) - }) - - it('settles reruns immediately when motion is reduced', () => { - reducedMotion = true - act(() => completeIntro(true)) - act(() => runWorkflow()) - expect(builtCount).toBe(6) - expect(vi.getTimerCount()).toBe(0) - }) - - it('settles an active rerun when reduced motion is enabled', () => { - act(() => completeIntro(true)) - act(() => runWorkflow()) - advance(400) - expect(builtCount).toBe(2) - act(() => { - reducedMotion = true - motionListeners.forEach((listener) => listener()) - }) - expect(builtCount).toBe(6) - expect(vi.getTimerCount()).toBe(0) - }) -}) diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-stage/hero-platform-stage.test.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-stage/hero-platform-stage.test.tsx deleted file mode 100644 index 11e5eb66f3f..00000000000 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-stage/hero-platform-stage.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** @vitest-environment node */ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { HERO_ARTWORK } from '@/app/(landing)/components/hero/components/hero-platform-stage/hero-artwork.generated' -import { HeroPlatformStage } from '@/app/(landing)/components/hero/components/hero-platform-stage/hero-platform-stage' - -vi.mock('@sim/emcn', async (importOriginal) => ({ - ...(await importOriginal()), - cn: (...values: string[]) => values.join(' '), -})) -vi.mock('@/app/(landing)/components/hero/components/hero-platform-loop', () => ({ - HeroPlatformLoopMount: () => null, -})) -vi.mock( - '@/app/(landing)/components/hero/components/hero-platform-stage/mobile-hero-workflow', - () => ({ - MobileHeroWorkflow: () => null, - }) -) - -describe('HeroPlatformStage loading', () => { - it('preloads only the desktop AVIF selection and keeps the hidden image lazy on mobile', () => { - const html = renderToStaticMarkup() - const preloads = html.match(/]*rel="preload"[^>]*>/g) ?? [] - expect(preloads).toHaveLength(1) - expect(preloads[0]).toContain('media="(min-width: 1024px)"') - expect(preloads[0]).toContain('type="image/avif"') - expect(preloads[0]).toContain(`imageSrcSet="${HERO_ARTWORK.avifSrcSet}"`) - expect(html).toContain(`srcSet="${HERO_ARTWORK.avifSrcSet}"`) - expect(html).toContain(`srcSet="${HERO_ARTWORK.webpSrcSet}"`) - expect(html).toContain('loading="lazy"') - expect(html).not.toContain('/_next/image') - }) -}) diff --git a/apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.test.tsx b/apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.test.tsx index 65c866c8a59..726d56cba28 100644 --- a/apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.test.tsx +++ b/apps/sim/app/(landing)/components/landing-cta-link/landing-cta-link.test.tsx @@ -15,7 +15,6 @@ let root: Root let host: HTMLDivElement beforeEach(() => { - vi.clearAllMocks() vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) host = document.createElement('div') document.body.append(host) @@ -47,34 +46,4 @@ describe('LandingCtaLink', () => { expect(internal.hasAttribute('target')).toBe(false) expect(internal.hasAttribute('rel')).toBe(false) }) - - it('reports a tracked click with the href as its destination', () => { - const onClick = vi.fn((event: React.MouseEvent) => event.preventDefault()) - const anchor = renderLink( - - Start building - - ) - act(() => anchor.click()) - expect(onClick).toHaveBeenCalledOnce() - expect(mockCaptureClientEvent).toHaveBeenCalledExactlyOnceWith('landing_cta_clicked', { - label: 'Start building', - section: 'footer_cta', - destination: '/signup', - }) - }) - - it('stays silent without a tracking request', () => { - const anchor = renderLink( - event.preventDefault()}> - Sign up - - ) - act(() => anchor.click()) - expect(mockCaptureClientEvent).not.toHaveBeenCalled() - }) }) diff --git a/apps/sim/app/(landing)/components/landing-shell/landing-shell.test.tsx b/apps/sim/app/(landing)/components/landing-shell/landing-shell.test.tsx deleted file mode 100644 index dba693aa8e2..00000000000 --- a/apps/sim/app/(landing)/components/landing-shell/landing-shell.test.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { readdirSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { type AnchorHTMLAttributes, act, type ReactNode } from 'react' -import ts from '@typescript/typescript6' -import { createRoot, type Root } from 'react-dom/client' -import { renderToStaticMarkup } from 'react-dom/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { FOOTER_ARTWORK } from '@/app/(landing)/components/cta/footer-artwork.generated' -import { LandingShell } from '@/app/(landing)/components/landing-shell/landing-shell' - -interface TestLinkProps extends AnchorHTMLAttributes { - href: string -} - -const { setTheme } = vi.hoisted(() => ({ setTheme: vi.fn() })) - -vi.mock('@sim/emcn', async (importOriginal) => ({ - ...(await importOriginal()), - cn: (...values: unknown[]) => values.flat().filter(Boolean).join(' '), - ChipLink: ({ href, children, className }: TestLinkProps) => ( - - {children} - - ), -})) -vi.mock('@sim/emcn/icons', () => ({ Moon: () => null, Sun: () => null })) -vi.mock('next/link', () => ({ - default: ({ href, children, ...props }: TestLinkProps) => ( - - {children} - - ), -})) -vi.mock('next/image', () => ({ - default: ({ src, alt }: { src: string; alt: string }) => {alt}, -})) -vi.mock('next-themes', () => ({ - useTheme: () => ({ resolvedTheme: 'light', setTheme }), -})) -vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true })) -vi.mock('@/lib/github/stars', () => ({ getGitHubStars: vi.fn().mockResolvedValue(29_500) })) -vi.mock('@/app/(landing)/components/navbar/navbar', () => ({ - Navbar: () =>
} />)) -}) - -afterEach(() => { - act(() => root.unmount()) - host.remove() - vi.unstubAllGlobals() -}) - -function element(selector: string): HTMLElement { - const result = host.querySelector(selector) - if (!result) throw new Error(`Missing navigation element: ${selector}`) - return result -} - -function hover(target: HTMLElement) { - act(() => { - target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) - }) -} - -function expectSelected(href: string, kind: string) { - const panel = element('#primary-navigation-mega-menu') - expect(panel.getAttribute('aria-hidden')).toBe('false') - const activeLinks = panel.querySelectorAll('a[data-active="true"]') - expect(activeLinks).toHaveLength(1) - expect(activeLinks[0].getAttribute('href')).toBe(href) - expect(panel.querySelector('output')?.textContent).toBe(kind) -} - -describe('NavMenuCluster feature selection', () => { - it('mounts the preview on first opening and preserves it during the exit transition', () => { - expect(host.querySelector('output')).toBeNull() - hover(element('#nav-platform-menu-trigger')) - expect(host.querySelector('output')).not.toBeNull() - act(() => { - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) - }) - expect(element('#primary-navigation-mega-menu').getAttribute('aria-hidden')).toBe('true') - expect(host.querySelector('output')).not.toBeNull() - }) - - it('prefetches destinations only while their menu is open', () => { - const overview = element('a[href="/platform"]') - const customers = element('#nav-customers-menu a[href="/customers/rivian"]') - expect(overview.dataset.prefetch).toBe('disabled') - expect(customers.dataset.prefetch).toBe('disabled') - - hover(element('#nav-platform-menu-trigger')) - expect(overview.dataset.prefetch).toBe('auto') - expect(customers.dataset.prefetch).toBe('disabled') - - hover(element('#nav-customers-menu-trigger')) - expect(overview.dataset.prefetch).toBe('disabled') - expect(customers.dataset.prefetch).toBe('auto') - - act(() => { - customers.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) - }) - expect(customers.dataset.prefetch).toBe('disabled') - }) - - it('lets Tab enter Platform links, return to its trigger, and continue to Customers', () => { - const platform = element('#nav-platform-menu-trigger') - act(() => platform.focus()) - act(() => { - platform.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })) - }) - const overview = element('a[href="/platform"]') - expect(document.activeElement).toBe(overview) - expectSelected('/platform', 'overview') - - act(() => { - overview.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }) - ) - }) - expect(document.activeElement).toBe(platform) - - const logs = element('a[href="/logs"]') - act(() => logs.focus()) - act(() => { - logs.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })) - }) - expect(document.activeElement).toBe(element('#nav-customers-menu-trigger')) - expect(element('#nav-customers-menu').getAttribute('aria-hidden')).toBe('false') - }) - - it('supports arrow entry and Escape returns focus without reopening the menu', () => { - const platform = element('#nav-platform-menu-trigger') - act(() => platform.focus()) - act(() => { - platform.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) - }) - expect(document.activeElement).toBe(element('a[href="/logs"]')) - - act(() => { - document.activeElement?.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) - ) - }) - expect(document.activeElement).toBe(platform) - expect(platform.getAttribute('aria-expanded')).toBe('false') - - act(() => platform.click()) - expect(document.activeElement).toBe(element('a[href="/platform"]')) - }) - - it('releases the desktop scroll lock when resized below the visible breakpoint', () => { - hover(element('#nav-platform-menu-trigger')) - setMenuOpen.mockClear() - act(() => { - desktopMedia.matches = false - desktopMedia.dispatchEvent(new Event('change')) - }) - expect(element('#nav-platform-menu-trigger').getAttribute('aria-expanded')).toBe('false') - expect(setMenuOpen).toHaveBeenCalledExactlyOnceWith('desktop', false) - }) - - it('reports opening and pointer-exit closure during the event before effects flush', () => { - const platform = element('#nav-platform-menu-trigger') - - act(() => { - platform.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) - expect(setMenuOpen).toHaveBeenCalledExactlyOnceWith('desktop', true) - }) - - setMenuOpen.mockClear() - - act(() => { - platform.dispatchEvent( - new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }) - ) - expect(setMenuOpen).toHaveBeenCalledExactlyOnceWith('desktop', false) - }) - }) - - it('stays open while the pointer crosses the header corridor into a menu link', () => { - const platform = element('#nav-platform-menu-trigger') - hover(platform) - const bridge = element('[data-navigation-hover-bridge]') - const overview = element('a[href="/platform"]') - setMenuOpen.mockClear() - - act(() => { - platform.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: bridge })) - bridge.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: platform })) - bridge.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: overview })) - overview.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: bridge })) - }) - - expectSelected('/platform', 'overview') - expect(setMenuOpen).not.toHaveBeenCalled() - - act(() => { - overview.dispatchEvent( - new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }) - ) - }) - - expect(platform.getAttribute('aria-expanded')).toBe('false') - expect(host.querySelector('[data-navigation-hover-bridge]')).toBeNull() - expect(setMenuOpen).toHaveBeenCalledExactlyOnceWith('desktop', false) - }) - - it('only bridges the full header for an open surface menu', () => { - expect(host.querySelector('[data-navigation-hover-bridge]')).toBeNull() - - hover(element('#nav-platform-menu-trigger')) - expect(host.querySelector('[data-navigation-hover-bridge]')).not.toBeNull() - - hover(element('#nav-customers-menu-trigger')) - expect(host.querySelector('[data-navigation-hover-bridge]')).toBeNull() - expect(element('#nav-customers-menu').getAttribute('aria-hidden')).toBe('false') - - hover(element('#nav-resources-menu-trigger')) - expect(host.querySelector('[data-navigation-hover-bridge]')).not.toBeNull() - }) - - it('opens on Overview and keeps the selected feature highlighted while viewing its preview', () => { - hover(element('#nav-platform-menu-trigger')) - expectSelected('/platform', 'overview') - - const workflows = element('a[href="/workflows"]') - hover(workflows) - expectSelected('/workflows', 'workflows') - - act(() => { - workflows.dispatchEvent( - new MouseEvent('mouseout', { - bubbles: true, - relatedTarget: element('output'), - }) - ) - }) - expectSelected('/workflows', 'workflows') - }) - - it('returns to Overview when the pointer returns to the open Platform trigger', () => { - const platform = element('#nav-platform-menu-trigger') - hover(platform) - hover(element('a[href="/tables"]')) - expectSelected('/tables', 'tables') - - hover(platform) - expect(platform.getAttribute('aria-expanded')).toBe('true') - expectSelected('/platform', 'overview') - }) - - it('starts with Overview again after closing and reopening Platform', () => { - const platform = element('#nav-platform-menu-trigger') - hover(platform) - const logs = element('a[href="/logs"]') - hover(logs) - expectSelected('/logs', 'logs') - - act(() => { - logs.dispatchEvent( - new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }) - ) - }) - expect(platform.getAttribute('aria-expanded')).toBe('false') - expect(element('#primary-navigation-mega-menu').getAttribute('aria-hidden')).toBe('true') - - hover(platform) - expectSelected('/platform', 'overview') - }) - - it('keeps keyboard selection and previews together when returning to a trigger or changing menus', () => { - const platform = element('#nav-platform-menu-trigger') - act(() => platform.focus()) - expectSelected('/platform', 'overview') - - act(() => element('a[href="/knowledge"]').focus()) - expectSelected('/knowledge', 'knowledge') - - act(() => platform.focus()) - expectSelected('/platform', 'overview') - - act(() => element('#nav-resources-menu-trigger').focus()) - expect(platform.getAttribute('aria-expanded')).toBe('false') - expectSelected('https://docs.sim.ai', 'docs') - - act(() => platform.focus()) - expectSelected('/platform', 'overview') - }) -}) diff --git a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx deleted file mode 100644 index 4160191677d..00000000000 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.test.tsx +++ /dev/null @@ -1,304 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - NavbarShell, - useNavbarFrost, -} from '@/app/(landing)/components/navbar/components/navbar-shell/navbar-shell' - -vi.mock('@sim/emcn', () => ({ - cn: (...values: Array) => values.filter(Boolean).join(' '), -})) - -let resizeObservers: ControlledResizeObserver[] -let intersectionObservers: ControlledIntersectionObserver[] - -class ControlledResizeObserver implements ResizeObserver { - observe = vi.fn<(target: Element) => void>() - unobserve = vi.fn<(target: Element) => void>() - disconnect = vi.fn() - - constructor(private readonly callback: ResizeObserverCallback) { - resizeObservers.push(this) - } - - resize(target: Element) { - const entry: ResizeObserverEntry = { - target, - contentRect: target.getBoundingClientRect(), - borderBoxSize: [], - contentBoxSize: [], - devicePixelContentBoxSize: [], - } - this.callback([entry], this) - } -} - -class ControlledIntersectionObserver { - observe = vi.fn<(target: Element) => void>() - disconnect = vi.fn() - - constructor( - readonly callback: IntersectionObserverCallback, - readonly options?: IntersectionObserverInit - ) { - intersectionObservers.push(this) - } -} - -function MenuControls() { - const frost = useNavbarFrost() - if (!frost) throw new Error('Menu controls require the actual navbar context') - - return ( - <> - - - - - - ) -} - -let host: HTMLDivElement -let root: Root -let mounted: boolean -let headerHeight: number - -function header(): HTMLElement { - const element = host.querySelector('[data-landing-header]') - if (!element) throw new Error('Missing landing header') - return element -} - -function click(label: string) { - const button = Array.from(host.querySelectorAll('button')).find( - (candidate) => candidate.textContent === label - ) - if (!button) throw new Error(`Missing menu control: ${label}`) - act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))) -} - -function announcement(): HTMLElement { - const element = host.querySelector('[data-test-announcement]')?.parentElement - if (!element) throw new Error('Missing announcement') - return element -} - -function scrollTo(position: number) { - act(() => { - host.scrollTop = position - host.dispatchEvent(new Event('scroll')) - }) -} - -function unmount() { - act(() => root.unmount()) - mounted = false -} - -beforeEach(() => { - resizeObservers = [] - intersectionObservers = [] - headerHeight = 104 - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - vi.stubGlobal('ResizeObserver', ControlledResizeObserver) - vi.stubGlobal('IntersectionObserver', ControlledIntersectionObserver) - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { - const height = this.tagName === 'HEADER' ? headerHeight : 32 - return new DOMRect(0, 0, 1440, height) - }) - vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(32) - - host = document.createElement('div') - host.style.overflowY = 'scroll' - host.style.paddingRight = '12px' - host.style.scrollPaddingTop = '8px' - host.scrollTop = 320 - Object.defineProperties(host, { - offsetWidth: { value: 1440 }, - clientWidth: { value: 1420 }, - scrollHeight: { value: 2000 }, - clientHeight: { value: 800 }, - }) - document.body.append(host) - root = createRoot(host) - mounted = true - act(() => { - root.render( - - Read update - - } - > - - - ) - }) -}) - -afterEach(() => { - if (mounted) unmount() - host.remove() - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -describe('NavbarShell menu positioning and scroll containment', () => { - it('publishes the current header height before a resize and updates it when header content changes', () => { - expect(header().style.getPropertyValue('--landing-header-height')).toBe( - 'calc(104px - var(--landing-announcement-offset, 0px))' - ) - expect(host.style.scrollPaddingTop).toBe('104px') - expect(resizeObservers).toHaveLength(1) - expect(resizeObservers[0].observe).toHaveBeenCalledWith(header()) - - headerHeight = 76 - act(() => resizeObservers[0].resize(header())) - - expect(header().style.getPropertyValue('--landing-header-height')).toBe( - 'calc(76px - var(--landing-announcement-offset, 0px))' - ) - expect(host.style.scrollPaddingTop).toBe('76px') - expect(host.scrollTop).toBe(320) - }) - - it('disconnects header and scroll-sentinel observations when the shell unmounts', () => { - expect(resizeObservers).toHaveLength(1) - expect(intersectionObservers).toHaveLength(1) - expect(intersectionObservers[0].options?.root).toBe(host) - - unmount() - - expect(resizeObservers[0].disconnect).toHaveBeenCalledOnce() - expect(intersectionObservers[0].disconnect).toHaveBeenCalledOnce() - expect(host.style.scrollPaddingTop).toBe('8px') - }) - - it('locks its actual parent scroll port and restores existing styles without changing its position', () => { - const bodyOverflow = document.body.style.overflowY - const bodyPadding = document.body.style.paddingRight - - click('Open desktop') - - expect(host.style.overflowY).toBe('hidden') - expect(host.style.paddingRight).toBe('20px') - expect(host.scrollTop).toBe(320) - expect(document.documentElement.style.overscrollBehaviorY).toBe('none') - expect(document.body.style.overflowY).toBe(bodyOverflow) - expect(document.body.style.paddingRight).toBe(bodyPadding) - - click('Close desktop') - - expect(host.style.overflowY).toBe('scroll') - expect(host.style.paddingRight).toBe('12px') - expect(host.scrollTop).toBe(320) - expect(document.documentElement.style.overscrollBehaviorY).toBe('') - }) - - it('keeps the scroll port locked until desktop and mobile menus have both closed', () => { - click('Open desktop') - click('Open mobile') - click('Close desktop') - - expect(host.style.overflowY).toBe('hidden') - expect(host.style.paddingRight).toBe('20px') - - click('Close mobile') - - expect(host.style.overflowY).toBe('scroll') - expect(host.style.paddingRight).toBe('12px') - }) -}) - -describe('NavbarShell announcement scroll behavior', () => { - it('hides on downward scroll and restores on upward scroll without changing the scroll position', () => { - expect(announcement().hasAttribute('inert')).toBe(false) - - scrollTo(400) - - expect(announcement().hasAttribute('inert')).toBe(true) - expect(announcement().getAttribute('aria-hidden')).toBe('true') - expect(host.style.scrollPaddingTop).toBe('104px') - expect(host.scrollTop).toBe(400) - - scrollTo(380) - - expect(announcement().hasAttribute('inert')).toBe(false) - expect(host.style.scrollPaddingTop).toBe('104px') - expect(host.scrollTop).toBe(380) - }) - - it('ignores small direction changes but accumulates slow scrolling', () => { - scrollTo(324) - expect(announcement().hasAttribute('inert')).toBe(false) - scrollTo(329) - expect(announcement().hasAttribute('inert')).toBe(true) - scrollTo(326) - expect(announcement().hasAttribute('inert')).toBe(true) - scrollTo(320) - expect(announcement().hasAttribute('inert')).toBe(false) - }) - - it('keeps the banner visible near the top and ignores overscroll bounce at both ends', () => { - scrollTo(400) - scrollTo(-30) - expect(announcement().hasAttribute('inert')).toBe(false) - scrollTo(10) - expect(announcement().hasAttribute('inert')).toBe(false) - - scrollTo(1200) - scrollTo(1250) - scrollTo(1200) - expect(announcement().hasAttribute('inert')).toBe(true) - scrollTo(1180) - expect(announcement().hasAttribute('inert')).toBe(false) - }) - - it('keeps the header stationary while a navigation menu is open', () => { - scrollTo(400) - click('Open mobile') - scrollTo(300) - expect(announcement().hasAttribute('inert')).toBe(true) - expect(host.style.scrollPaddingTop).toBe('104px') - - click('Close mobile') - scrollTo(280) - expect(announcement().hasAttribute('inert')).toBe(false) - }) - - it('does not hide a focused announcement link', () => { - host.querySelector('[data-test-announcement]')?.focus() - scrollTo(400) - expect(announcement().hasAttribute('inert')).toBe(false) - }) - - it('restores the full header if native focus scrolling reaches the top while a menu is open', () => { - scrollTo(400) - click('Open mobile') - scrollTo(0) - - expect(announcement().hasAttribute('inert')).toBe(false) - expect(host.style.scrollPaddingTop).toBe('104px') - expect(host.style.overflowY).toBe('hidden') - }) - - it('removes the scroll listener when the shell unmounts', () => { - const removeListener = vi.spyOn(host, 'removeEventListener') - unmount() - expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function)) - }) -}) diff --git a/apps/sim/app/(landing)/components/platform-suite/components/governance-loop/governance-loop.test.tsx b/apps/sim/app/(landing)/components/platform-suite/components/governance-loop/governance-loop.test.tsx deleted file mode 100644 index 67d543939de..00000000000 --- a/apps/sim/app/(landing)/components/platform-suite/components/governance-loop/governance-loop.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, type ReactNode } from 'react' -import { createRoot } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', () => ({ - Badge: ({ children }: { children: ReactNode }) => {children}, - cn: (...values: Array) => values.filter(Boolean).join(' '), -})) - -vi.mock('@sim/emcn/icons', () => ({ - Building: () => , - Download: () => , -})) - -vi.mock('@/app/(landing)/components/shared/responsive-design-stage', () => ({ - ResponsiveDesignStage: ({ children }: { children: ReactNode }) =>
{children}
, -})) - -vi.mock('@/app/(landing)/hooks/use-motion-safe-cycle', async () => { - const React = await import('react') - return { - useMotionSafeCycle: ({ - scheduleCycle, - }: { - scheduleCycle: () => { timers: ReturnType[]; totalMs: number } - }) => { - React.useEffect(() => { - const cycle = scheduleCycle() - return () => cycle.timers.forEach(clearTimeout) - }, []) - }, - } -}) - -import { GovernanceLoop } from '@/app/(landing)/components/platform-suite/components/governance-loop' - -afterEach(() => { - vi.useRealTimers() - document.body.replaceChildren() -}) - -describe('GovernanceLoop', () => { - it('lands the workspaces, fills spend against the budget, and trips the near-limit status', () => { - vi.useFakeTimers() - const host = document.createElement('div') - document.body.append(host) - act(() => { - createRoot(host).render() - }) - - expect(host.textContent).toContain('Organization') - expect(host.textContent).toContain('Spend this month') - const rows = () => [...host.querySelectorAll('tbody tr')] - expect(rows()).toHaveLength(5) - expect(rows().every((row) => row.className.includes('opacity-0'))).toBe(true) - expect(host.textContent).toContain('$0 of $6,000') - expect(host.textContent).not.toContain('Near limit') - - act(() => { - vi.advanceTimersByTime(3_000) - }) - expect(rows().every((row) => row.className.includes('opacity-100'))).toBe(true) - expect(host.textContent).toContain('$3,480 of $6,000') - expect(host.textContent).toContain('58% of the org budget') - expect(host.textContent).toContain('Near limit') - }) -}) diff --git a/apps/sim/app/(landing)/components/platform-suite/platform-suite.test.tsx b/apps/sim/app/(landing)/components/platform-suite/platform-suite.test.tsx deleted file mode 100644 index 62ccb1c9657..00000000000 --- a/apps/sim/app/(landing)/components/platform-suite/platform-suite.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { type AnchorHTMLAttributes, act, type ReactNode } from 'react' -import { createRoot } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', async (importOriginal) => ({ - ...(await importOriginal()), - cn: (...values: Array) => values.filter(Boolean).join(' '), -})) - -vi.mock('next/link', () => ({ - default: ({ - children, - href, - ...props - }: { children: ReactNode; href: string } & Omit< - AnchorHTMLAttributes, - 'href' - >) => ( - - {children} - - ), -})) - -vi.mock('@/app/(landing)/components/placement-frame', () => ({ - PlacementFrame: ({ className }: { className?: string }) =>
, -})) - -vi.mock('@/app/(landing)/components/shared/product-window', () => ({ - ProductWindow: ({ kind }: { kind: string }) =>
, -})) - -import { PlatformSuite } from '@/app/(landing)/components/platform-suite/platform-suite' - -afterEach(() => { - document.body.replaceChildren() -}) - -describe('PlatformSuite', () => { - it('balances the card copy and redistributes card width on hover', () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const host = document.createElement('div') - document.body.appendChild(host) - const root = createRoot(host) - - act(() => { - root.render() - }) - - expect(host.textContent).toContain('One Platform for every AI Agent') - expect(host.textContent).toContain( - 'Build and deploy agents in one collaborative workspace, with centralized control over access, spend, data, and performance.' - ) - expect(host.textContent).toContain('Build agents') - expect(host.textContent).toContain('Govern at scale') - - const buildCard = host.querySelector('[data-platform-card="0"]') as HTMLElement - const governCard = host.querySelector('[data-platform-card="1"]') as HTMLElement - expect(buildCard.tagName).toBe('DIV') - expect(buildCard.querySelector('h3 a')?.getAttribute('href')).toBe('/workflows') - expect(buildCard.querySelector('h3 a')?.className).toContain('after:absolute after:inset-0') - expect(governCard.querySelector('h3 a')?.getAttribute('href')).toBe('/enterprise') - expect(buildCard.querySelector('svg.iso-integrate-illustration')).not.toBeNull() - expect(governCard.querySelector('svg.iso-monitor-illustration')).not.toBeNull() - const buildMark = buildCard.querySelector('svg') as SVGElement - const buildTitle = buildCard.querySelector('h3') as HTMLElement - expect( - buildMark.compareDocumentPosition(buildTitle) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy() - const governContent = governCard.querySelector('[data-platform-card-content]') as HTMLElement - expect(governContent.className).not.toContain('opacity-40') - - expect(buildCard.className).toContain('md:grow') - expect(governCard.className).toContain('md:grow') - - act(() => { - buildCard.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) - }) - - expect(buildCard.className).toContain('md:grow-[1.25]') - expect(governCard.className).toContain('md:grow-[0.75]') - expect(governContent.className).toContain('opacity-40') - expect( - (buildCard.querySelector('[data-platform-card-content]') as HTMLElement).className - ).not.toContain('opacity-40') - - act(() => { - buildCard.dispatchEvent( - new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }) - ) - }) - - expect(buildCard.className).toContain('md:grow') - expect(governCard.className).toContain('md:grow') - - act(() => { - root.unmount() - }) - }) -}) diff --git a/apps/sim/app/(landing)/components/product-demo/components/composer-loop/composer-loop.test.tsx b/apps/sim/app/(landing)/components/product-demo/components/composer-loop/composer-loop.test.tsx deleted file mode 100644 index fca3d17c585..00000000000 --- a/apps/sim/app/(landing)/components/product-demo/components/composer-loop/composer-loop.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, type ReactNode } from 'react' -import { usePrefersReducedMotion } from '@sim/emcn/hooks/use-prefers-reduced-motion' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', () => ({ - cn: (...values: Array) => values.filter(Boolean).join(' '), - usePrefersReducedMotion, -})) -vi.mock('@sim/emcn/icons', () => ({ Blimp: () => null, Table: () => null })) -vi.mock('@/components/ui', () => ({ ThinkingLoader: () => null })) -vi.mock('framer-motion', () => ({ - AnimatePresence: ({ children }: { children: ReactNode }) => children, - MotionConfig: ({ children }: { children: ReactNode }) => children, - motion: { - div: ({ children }: { children: ReactNode }) =>
{children}
, - span: ({ children }: { children: ReactNode }) => {children}, - }, -})) -vi.mock( - '@/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage', - () => ({ - ProductionWorkflowStage: ({ builtCount }: { builtCount: number }) => ( -
- ), - }) -) -vi.mock('@/app/(landing)/components/product-demo/components/composer-loop/demo-composer', () => ({ - DemoComposer: ({ prompt }: { prompt: string }) =>
{prompt}
, -})) - -import { ComposerLoop } from '@/app/(landing)/components/product-demo/components/composer-loop/composer-loop' -import { DEMO_BLOCKS } from '@/app/(landing)/components/product-demo/components/composer-loop/demo-workflow-data' - -let root: Root -let host: HTMLDivElement -let reducedMotion: boolean -let motionListeners: Set<() => void> - -beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - reducedMotion = false - motionListeners = new Set() - vi.stubGlobal('matchMedia', () => ({ - get matches() { - return reducedMotion - }, - addEventListener: (_event: string, callback: () => void) => motionListeners.add(callback), - removeEventListener: (_event: string, callback: () => void) => motionListeners.delete(callback), - })) - vi.stubGlobal('IntersectionObserver', undefined) - vi.stubGlobal('ResizeObserver', undefined) - host = document.createElement('div') - document.body.append(host) - root = createRoot(host) -}) - -afterEach(() => { - act(() => root.unmount()) - host.remove() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -function setReducedMotion(value: boolean) { - act(() => { - reducedMotion = value - motionListeners.forEach((listener) => listener()) - }) -} - -describe('ComposerLoop motion preference', () => { - it('shows the completed workflow without starting decorative timers when motion is reduced', () => { - reducedMotion = true - const onBeat = vi.fn() - act(() => root.render()) - - expect(host.querySelector('[data-workflow-blocks]')?.getAttribute('data-workflow-blocks')).toBe( - String(DEMO_BLOCKS.length) - ) - expect(host.querySelector('[data-composer]')).toBeNull() - expect(onBeat).toHaveBeenLastCalledWith('build') - expect(vi.getTimerCount()).toBe(0) - - act(() => vi.advanceTimersByTime(60_000)) - expect(host.querySelector('[data-composer]')).toBeNull() - expect(vi.getTimerCount()).toBe(0) - }) - - it('cancels an active pass immediately when reduced motion is enabled', () => { - const onBeat = vi.fn() - act(() => root.render()) - act(() => vi.advanceTimersByTime(1_100)) - expect(host.querySelector('[data-composer]')?.textContent).not.toBe('') - expect(vi.getTimerCount()).toBeGreaterThan(0) - - setReducedMotion(true) - expect(host.querySelector('[data-composer]')).toBeNull() - expect(host.querySelector('[data-workflow-blocks]')?.getAttribute('data-workflow-blocks')).toBe( - String(DEMO_BLOCKS.length) - ) - expect(vi.getTimerCount()).toBe(0) - act(() => vi.advanceTimersByTime(60_000)) - expect(onBeat).toHaveBeenLastCalledWith('build') - }) - - it('starts a fresh pass when motion is enabled again and unsubscribes on unmount', () => { - reducedMotion = true - act(() => root.render()) - setReducedMotion(false) - expect(host.querySelector('[data-composer]')?.textContent).toBe('') - act(() => vi.advanceTimersByTime(1_100)) - expect(host.querySelector('[data-composer]')?.textContent).not.toBe('') - - act(() => root.unmount()) - expect(vi.getTimerCount()).toBe(0) - expect(motionListeners.size).toBe(0) - }) -}) diff --git a/apps/sim/app/(landing)/components/product-demo/components/composer-loop/demo-composer.test.tsx b/apps/sim/app/(landing)/components/product-demo/components/composer-loop/demo-composer.test.tsx deleted file mode 100644 index a0d1896cfa4..00000000000 --- a/apps/sim/app/(landing)/components/product-demo/components/composer-loop/demo-composer.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @vitest-environment node - */ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { - COMPOSER_ACTIONS, - DemoComposer, -} from '@/app/(landing)/components/product-demo/components/composer-loop/demo-composer' - -describe('DemoComposer', () => { - it('rests on the initial placeholder with the product toolbar', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Ask Sim to') - for (const action of COMPOSER_ACTIONS) { - expect(html).toContain(`data-action="${action}"`) - } - expect(html).toContain('bg-[#808080]') - }) - - it('shows the typed prompt and arms the send disc', () => { - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Turn my launch notes') - expect(html).toContain('bg-[#383838]') - expect(html).not.toContain('fill-white') - }) - - it('switches to the conversation placeholder and the stop square while sending', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('Send message to Sim') - expect(html).toContain('fill-white') - }) - - it('separates the placeholder and the typed prompt by token', () => { - const resting = renderToStaticMarkup() - const typed = renderToStaticMarkup( - - ) - - expect(resting).toContain('text-[var(--text-muted)]') - expect(typed).toContain('text-[var(--text-primary)]') - expect(typed).not.toContain('text-[var(--text-muted)]') - }) - - it('keeps every control out of the tab order', () => { - const html = renderToStaticMarkup() - - expect(html).not.toContain(' { - const ids = new Set(DEMO_BLOCKS.map((block) => block.id)) - - it('wires every edge between known blocks, escalation branch first out of the condition', () => { - for (const [source, target] of DEMO_EDGES) { - expect(ids.has(source)).toBe(true) - expect(ids.has(target)).toBe(true) - } - const conditionTargets = DEMO_EDGES.filter(([source]) => source === 'severity').map( - ([, target]) => target - ) - expect(conditionTargets).toEqual(['page', 'draft']) - }) - - it('opens with the Start block, then a trigger, and ends on terminals', () => { - expect(DEMO_BLOCKS[0].id).toBe('start') - expect(DEMO_BLOCKS[0].isTrigger).toBe(true) - expect(DEMO_BLOCKS[1].isTrigger).toBe(true) - for (const id of ['log', 'alert']) { - expect(DEMO_BLOCKS.find((block) => block.id === id)?.isTerminal).toBe(true) - } - }) - - it('never stacks two cards on top of each other', () => { - for (const a of DEMO_BLOCKS) { - for (const b of DEMO_BLOCKS) { - if (a === b || a.x !== b.x) continue - expect(Math.abs(a.y - b.y)).toBeGreaterThanOrEqual(150) - } - } - }) - - it('keeps every card inside the design canvas', () => { - for (const block of DEMO_BLOCKS) { - expect(block.x).toBeGreaterThanOrEqual(0) - expect(block.x + 250).toBeLessThanOrEqual(DEMO_CANVAS.width) - expect(block.y).toBeGreaterThanOrEqual(0) - expect(block.y).toBeLessThan(DEMO_CANVAS.height) - } - }) -}) diff --git a/apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/product-demo-caption.test.tsx b/apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/product-demo-caption.test.tsx deleted file mode 100644 index 1c1224f5bb7..00000000000 --- a/apps/sim/app/(landing)/components/product-demo/components/product-demo-caption/product-demo-caption.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { createRoot } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', async (importOriginal) => ({ - ...(await importOriginal()), - cn: (...values: Array) => values.filter(Boolean).join(' '), -})) - -import { - ProductDemoBeatProvider, - ProductDemoCaption, - useProductDemoBeat, -} from '@/app/(landing)/components/product-demo/components/product-demo-caption' -import styles from '@/app/(landing)/components/product-demo/components/product-demo-caption/product-demo-caption.module.css' - -function BuildButton() { - const { setBeat } = useProductDemoBeat() - return ( - - ) -} - -afterEach(() => { - vi.useRealTimers() - document.body.replaceChildren() -}) - -describe('ProductDemoCaption', () => { - it('opens still on the describe title, then crossfades to the next beat', () => { - vi.useFakeTimers() - const host = document.createElement('div') - document.body.append(host) - act(() => { - createRoot(host).render( - - - - - ) - }) - - const heading = () => host.querySelector('h2#product-demo-heading') - const titles = () => host.querySelectorAll('[data-product-demo-caption] > *') - expect(heading()?.textContent).toBe('Describe the agent.') - expect(heading()?.className).not.toContain(styles.enter) - expect(titles()).toHaveLength(1) - - act(() => { - host.querySelector('button')?.click() - }) - expect(heading()?.textContent).toBe('Watch the workflow build.') - expect(heading()?.className).toContain(styles.enter) - const stacked = titles() - expect(stacked).toHaveLength(2) - expect(stacked[0].getAttribute('aria-hidden')).toBe('true') - expect(stacked[0].className).toContain('opacity-0') - expect(stacked[0].textContent).toBe('Describe the agent.') - - act(() => { - vi.advanceTimersByTime(320) - }) - expect(titles()).toHaveLength(1) - expect(heading()?.textContent).toBe('Watch the workflow build.') - }) -}) diff --git a/apps/sim/app/(landing)/components/security/components/cert-blocs/cert-blocs.test.tsx b/apps/sim/app/(landing)/components/security/components/cert-blocs/cert-blocs.test.tsx deleted file mode 100644 index 496ac9767df..00000000000 --- a/apps/sim/app/(landing)/components/security/components/cert-blocs/cert-blocs.test.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { renderToStaticMarkup } from 'react-dom/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { CertBlocs } from '@/app/(landing)/components/security/components/cert-blocs' - -function renderCerts() { - return -} - -describe('CertBlocs', () => { - it('matches the Features stage aspect on white card chrome with dash-normalized strokes', () => { - const html = renderToStaticMarkup(renderCerts()) - - expect(html).toContain('aspect-[5/6]') - expect(html).toContain('rounded-[10px]') - expect(html).toContain('w-[56%]') - expect(html).toContain('bg-[var(--surface-2)]') - expect(html).toContain('border-[var(--border)]') - expect(html).not.toContain('rounded-[12px]') - expect(html).toContain('href="https://trust.sim.ai/"') - expect(html).toContain('pathLength="1"') - expect(html).not.toContain(' { - const html = renderToStaticMarkup(renderCerts()) - - const document = new DOMParser().parseFromString(html, 'text/html') - const stars = document.querySelectorAll('[data-cert-stars] path') - expect(stars).toHaveLength(12) - expect([...stars].every((star) => star.getAttribute('pathLength') === '1')).toBe(true) - expect(html).toContain('in the Sim Trust Center') - }) -}) - -describe('CertBlocs draw trigger', () => { - let observe: ReturnType - let disconnect: ReturnType - let observerCallback: IntersectionObserverCallback | undefined - let observerRoot: Element | Document | null | undefined - let root: Root | null - let host: HTMLDivElement | null - let port: HTMLDivElement | null - - beforeEach(() => { - observe = vi.fn() - disconnect = vi.fn() - observerCallback = undefined - observerRoot = undefined - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - - vi.stubGlobal( - 'IntersectionObserver', - class { - constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { - observerCallback = callback - observerRoot = options?.root - } - observe = observe - disconnect = disconnect - unobserve = vi.fn() - takeRecords = () => [] - root = null - rootMargin = '' - thresholds = [] - } - ) - - port = document.createElement('div') - port.className = 'h-screen overflow-y-auto' - document.body.append(port) - host = document.createElement('div') - port.append(host) - root = createRoot(host) - vi.stubGlobal('matchMedia', () => ({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - })) - }) - - afterEach(() => { - act(() => root?.unmount()) - root = null - host?.remove() - host = null - port?.remove() - port = null - vi.unstubAllGlobals() - }) - - it('draws once when the row enters the inner landing scroll port', () => { - vi.stubGlobal('matchMedia', () => ({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - })) - - act(() => { - root?.render(renderCerts()) - }) - - expect(observerRoot).toBe(port) - expect(observe).toHaveBeenCalledTimes(1) - - act(() => { - observerCallback?.( - [{ isIntersecting: true } as IntersectionObserverEntry], - {} as IntersectionObserver - ) - }) - - expect(disconnect).toHaveBeenCalled() - expect(host?.querySelector('ul')?.className).toMatch(/drawn/) - }) - - it('shows marks fully drawn when the user prefers reduced motion', () => { - vi.stubGlobal('matchMedia', () => ({ - matches: true, - addEventListener: () => {}, - removeEventListener: () => {}, - })) - - act(() => { - root?.render(renderCerts()) - }) - - expect(observe).not.toHaveBeenCalled() - expect(host?.querySelector('ul')?.className).toMatch(/drawn/) - }) - - it('replays the hovered GDPR mark and stars on every entry and cancels unfinished strokes', () => { - act(() => root?.render(renderCerts())) - const card = Array.from(host?.querySelectorAll('a') ?? []).find((link) => - link.textContent?.startsWith('GDPR') - ) - expect(card).toBeTruthy() - /** JSDOM does not inherit the CSS module's custom properties. */ - card?.style.setProperty('--draw-duration-ms', '900') - card?.style.setProperty('--detail-delay-ms', '120') - const cancel = vi.fn() - const strokes = Array.from(host?.querySelectorAll('[pathLength]') ?? []).map((stroke) => { - const animate = vi.fn() - Object.assign(stroke, { animate, getAnimations: () => [{ cancel }] }) - return { stroke, animate } - }) - - for (let entry = 1; entry <= 2; entry += 1) { - act(() => { - card?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })) - }) - - for (const { stroke, animate } of strokes) { - expect(animate).toHaveBeenCalledTimes(card?.contains(stroke) ? entry : 0) - if (card?.contains(stroke)) { - expect(animate).toHaveBeenLastCalledWith( - expect.any(Array), - expect.objectContaining({ duration: 900 }) - ) - } - } - - act(() => { - card?.dispatchEvent(new MouseEvent('pointerout', { bubbles: true })) - }) - } - - expect(cancel).toHaveBeenCalledTimes((card?.querySelectorAll('[pathLength]').length ?? 0) * 2) - }) - - it('does not replay for touch entry or reduced motion', () => { - act(() => root?.render(renderCerts())) - const card = host?.querySelector('a') - const animate = vi.fn() - for (const stroke of host?.querySelectorAll('[pathLength]') ?? []) { - Object.assign(stroke, { animate, getAnimations: () => [] }) - } - - const touch = new MouseEvent('pointerover', { bubbles: true }) - Object.defineProperty(touch, 'pointerType', { value: 'touch' }) - act(() => card?.dispatchEvent(touch)) - expect(animate).not.toHaveBeenCalled() - - vi.stubGlobal('matchMedia', () => ({ - matches: true, - addEventListener: () => {}, - removeEventListener: () => {}, - })) - act(() => card?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }))) - expect(animate).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/(landing)/components/security/components/workspace-controls/workspace-controls.test.tsx b/apps/sim/app/(landing)/components/security/components/workspace-controls/workspace-controls.test.tsx deleted file mode 100644 index 46515e4c80f..00000000000 --- a/apps/sim/app/(landing)/components/security/components/workspace-controls/workspace-controls.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @vitest-environment node - */ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', async (importOriginal) => ({ - ...(await importOriginal()), - cn: (...values: Array) => values.filter(Boolean).join(' '), -})) - -import { WorkspaceControls } from '@/app/(landing)/components/security/components/workspace-controls/workspace-controls' - -describe('WorkspaceControls', () => { - it('renders six untitled controls on an even three-column grid', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('id="controls"') - expect(html).toContain('aria-label="Workspace controls"') - expect(html).toContain('grid-cols-3') - expect(html).toContain('max-lg:grid-cols-2') - expect(html).toContain('max-sm:grid-cols-1') - expect(html.match(/
  • SSO & SCIM') - expect(html).toContain('>Self-hosting') - expect(html).not.toContain('Open source') - }) - - it('shifts every mark off the 64-grid origin so its stroke sits flush with the text', () => { - const html = renderToStaticMarkup() - const viewBoxes = [...html.matchAll(/viewBox="([^"]+)"/g)].map(([, value]) => value) - - expect(viewBoxes).toHaveLength(6) - for (const viewBox of viewBoxes) { - const [minX] = viewBox.split(' ').map(Number) - expect(minX).toBeGreaterThan(12) - } - }) - - it('carries no heading of its own', () => { - const html = renderToStaticMarkup() - - expect(html).not.toContain('How the workspace runs') - expect(html).not.toContain(' ({ - ...(await importOriginal()), - ChipLink: ({ - variant: _variant, - ...props - }: AnchorHTMLAttributes & { variant?: string }) => , - cn: (...values: Array) => values.filter(Boolean).join(' '), -})) - -import { Security } from '@/app/(landing)/components/security/security' - -describe('Security', () => { - it('renders the governance intro and white certification blocs', () => { - const html = renderToStaticMarkup() - - expect(html).toContain('id="security"') - expect(html).not.toContain('min-h-[100dvh]') - expect(html).toContain('>Central governance for enterprise AI') - expect(html).toContain( - 'Sim is one place to control who can build agents, what they can use, and how the workspace runs.' - ) - expect(html).toContain('>SOC 2 Type II') - expect(html).toContain('>ISO 27001') - expect(html).toContain('>GDPR') - expect(html).toContain('href="https://trust.sim.ai/"') - expect(html).toContain('bg-[var(--surface-2)]') - expect(html).toContain('aspect-[5/6]') - expect(html).toContain('rounded-[10px]') - expect(html).not.toContain('rounded-[12px]') - expect(html).not.toContain('How the workspace runs') - expect(html).not.toContain('Self-hosting') - expect(html).not.toContain('SOC2 compliant') - expect(html).not.toContain('HIPAA') - expect(html).not.toContain('CCPA') - }) -}) diff --git a/apps/sim/app/(landing)/components/shared/logs-run-graph/logs-run-graph.test.tsx b/apps/sim/app/(landing)/components/shared/logs-run-graph/logs-run-graph.test.tsx deleted file mode 100644 index bfd6eb520dd..00000000000 --- a/apps/sim/app/(landing)/components/shared/logs-run-graph/logs-run-graph.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { CoreFeatureCard } from '@/app/(landing)/components/features/components/core-feature-card' -import { LogsRunGraph } from '@/app/(landing)/components/shared/logs-run-graph' -import { RunTraceGraphic } from '@/app/(landing)/logs/components/feature-graphics' - -let root: Root -let host: HTMLDivElement - -beforeEach(() => { - vi.useFakeTimers() - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - host = document.createElement('div') - document.body.append(host) - root = createRoot(host) -}) - -afterEach(() => { - act(() => root.unmount()) - host.remove() - vi.restoreAllMocks() - vi.useRealTimers() -}) - -function mount() { - act(() => root.render()) - const graph = host.querySelector('button') - if (!graph) throw new Error('Graph not rendered') - return graph -} - -function pointer(element: Element, type: string, pointerType = 'touch', x = 40, y = 300) { - const event = new MouseEvent(type, { bubbles: true, clientX: x, clientY: y }) - Object.defineProperty(event, 'pointerType', { value: pointerType }) - act(() => element.dispatchEvent(event)) -} - -describe('LogsRunGraph', () => { - it('keeps tapped details visible and dismisses them on outside interaction', () => { - const graph = mount() - const bar = graph.querySelectorAll('[data-run-count]')[1] - pointer(bar, 'pointerdown') - pointer(bar, 'pointerup') - pointer(graph, 'pointerout') - expect(document.querySelector('[role="tooltip"]')?.textContent).toContain('5 succeeded') - expect(graph.getAttribute('aria-label')).toContain('23–22 hours ago') - pointer(document.body, 'pointerdown') - expect(document.querySelector('[role="tooltip"]')).toBeNull() - }) - - it.each(['pointercancel', 'swipe'])('does not open details after a %s gesture', (gesture) => { - const graph = mount() - pointer(graph, 'pointerdown') - if (gesture === 'pointercancel') pointer(graph, 'pointercancel') - pointer(graph, 'pointerup', 'touch', gesture === 'swipe' ? 120 : 40) - expect(document.querySelector('[role="tooltip"]')).toBeNull() - }) - - it.each(['scroll', 'resize'])( - 'dismisses details when the viewport changes through %s', - (event) => { - const graph = mount() - pointer(graph, 'pointerdown') - pointer(graph, 'pointerup') - expect(document.querySelector('[role="tooltip"]')).not.toBeNull() - act(() => (event === 'resize' ? window : host).dispatchEvent(new Event(event))) - expect(document.querySelector('[role="tooltip"]')).toBeNull() - } - ) - - it('supports keyboard exploration and Escape dismissal', () => { - const graph = mount() - act(() => graph.focus()) - act(() => graph.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }))) - expect(graph.getAttribute('aria-label')).toContain('Last hour: 7 succeeded') - act(() => - graph.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })) - ) - expect(graph.getAttribute('aria-label')).toContain('2–1 hours ago: 6 succeeded') - act(() => graph.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))) - expect(document.querySelector('[role="tooltip"]')).toBeNull() - }) - - it('keeps first-hour details inside a narrow viewport', () => { - const graph = mount() - vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(320) - pointer(graph, 'pointerdown') - pointer(graph, 'pointerup') - const tooltip = document.querySelector('[role="tooltip"]') - expect(tooltip?.style.translate).toContain('calc(192px - 100%)') - expect(tooltip?.className).toContain('w-[176px]') - }) - - it('keeps the interactive chart outside the route link and decorative wrappers', () => { - act(() => - root.render( - } - interactiveVisual - /> - ) - ) - const graph = host.querySelector('[data-run-overview-graph]') - expect(graph).not.toBeNull() - expect(graph?.closest('a, [aria-hidden="true"]')).toBeNull() - expect(host.querySelector('a')?.getAttribute('href')).toBe('/logs') - }) -}) diff --git a/apps/sim/app/(landing)/components/shared/product-window/product-window.test.tsx b/apps/sim/app/(landing)/components/shared/product-window/product-window.test.tsx deleted file mode 100644 index 18264f65db0..00000000000 --- a/apps/sim/app/(landing)/components/shared/product-window/product-window.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', async (importOriginal) => ({ - ...(await importOriginal()), - cn: (...values: Array) => values.filter(Boolean).join(' '), -})) -vi.mock('@/app/(landing)/components/shared/product-preview', () => ({ - ProductPreview: () => ( - <> -