diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d620ad7c92c..687fbfa5ecb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -30,7 +30,7 @@ Fixes # ## AI Disclosure - + ## Review diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 89c1c86096f..4591514322e 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -23,10 +23,9 @@ jobs: platform: linux - os: macos-15 platform: mac - # Why: Windows golden E2E is temporarily disabled on CI while its - # flaky runner-only failures are investigated. - # - os: windows-latest - # platform: windows + # Match the release gate: Windows runs the fresh-startup golden only. + - os: windows-2022 + platform: windows steps: - name: Checkout @@ -74,6 +73,14 @@ jobs: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + - name: Run golden E2E tests on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:SKIP_BUILD = '1' + $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run test:e2e:windows-fresh-startup-golden + - name: Upload Playwright traces if: failure() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 6dba5ac1ff6..5d9e4f29b72 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -783,7 +783,7 @@ jobs: { echo "## Release E2E Signal" echo "" - echo "- Terminal rendering golden is release-blocking." + echo "- Platform golden E2E is release-blocking: terminal rendering on Linux/macOS and fresh startup on Windows." echo "- Full E2E runs separately after publication and cannot change the release result." echo "- Terminal rendering release evidence is diagnostic/non-blocking." echo "" @@ -817,7 +817,7 @@ jobs: terminal-rendering-golden: needs: cut if: needs.cut.outputs.should_release == 'true' - name: terminal rendering golden ${{ matrix.platform }} + name: golden e2e ${{ matrix.platform }} runs-on: ${{ matrix.os }} timeout-minutes: 30 env: @@ -830,10 +830,10 @@ jobs: platform: linux - os: macos-15 platform: mac - # Why: Windows terminal rendering golden is temporarily disabled on - # CI while its flaky runner-only failures are investigated. - # - os: windows-latest - # platform: windows + # Windows terminal rendering remains flaky; keep its blocking signal + # scoped to the fresh-profile startup regression from #14130. + - os: windows-2022 + platform: windows steps: - name: Checkout @@ -866,7 +866,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build Electron app for terminal rendering golden + - name: Build Electron app for platform golden run: npx electron-vite build --mode e2e - name: Run terminal rendering golden on Linux @@ -877,11 +877,19 @@ jobs: if: runner.os == 'macOS' run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden + - name: Run fresh-startup golden on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:SKIP_BUILD = '1' + $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run test:e2e:windows-fresh-startup-golden + - name: Upload Playwright traces if: failure() uses: actions/upload-artifact@v7 with: - name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces + name: golden-e2e-${{ matrix.platform }}-playwright-traces path: test-results/ retention-days: 7 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 64854199a35..63c741afe42 100644 --- a/.gitignore +++ b/.gitignore @@ -126,6 +126,10 @@ playwright-report/ /.agents/skills/ /skills-lock.json +# Generated Clawpatch state includes machine-local paths and review records. +/.clawpatch/ +/mobile/.clawpatch/ + # Agent hook runtime endpoints (machine-local secrets) /agent-hooks/ validation-screenshots/ diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 1b695086808..2cf5e18af73 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -492,7 +492,7 @@ describe('Electron runtime package contract', () => { expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}') }) - it('keeps terminal rendering regressions in the manual golden E2E workflow', () => { + it('keeps platform golden regressions in the manual and release workflows', () => { const packageScripts = packageJson.scripts const goldenWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8') @@ -506,17 +506,19 @@ describe('Electron runtime package contract', () => { ['mac', 'macOS'], ['windows', 'Windows'] ]) - const goldenPlatforms = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include - .map(({ platform }) => platform) - .sort() - const goldenRunSteps = goldenPlatforms.map((platform) => { - const label = goldenPlatformLabels.get(platform) + const goldenMatrix = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include + const goldenPlatforms = goldenMatrix.map(({ platform }) => platform).sort() + const goldenRunSteps = new Map( + goldenPlatforms.map((platform) => { + const label = goldenPlatformLabels.get(platform) - expect(label, platform).toBeDefined() + expect(label, platform).toBeDefined() - return steps.find((step) => step.name === `Run golden E2E tests on ${label}`) - }) + return [platform, steps.find((step) => step.name === `Run golden E2E tests on ${label}`)] + }) + ) const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden'] + const releaseGoldenMatrix = releaseGoldenJob.strategy.matrix.include const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence'] const releaseBuildNeeds = releaseWorkflow.jobs.build.needs const publishReleaseNeeds = releaseWorkflow.jobs['publish-release'].needs @@ -535,15 +537,34 @@ describe('Electron runtime package contract', () => { expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain( 'terminal-long-table-scroll-restore.spec.ts' ) + expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( + 'golden-windows-fresh-startup.spec.ts' + ) + expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain( + '@windows-fresh-startup-golden' + ) expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( 'terminal-opencode-emoji-table-rendering.spec.ts' ) expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain( 'terminal-long-table-scroll-restore.spec.ts' ) - for (const runStep of goldenRunSteps) { - expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden') - } + expect(goldenMatrix).toEqual([ + { os: 'ubuntu-latest', platform: 'linux' }, + { os: 'macos-15', platform: 'mac' }, + { os: 'windows-2022', platform: 'windows' } + ]) + expect(goldenRunSteps.get('linux')?.run).toContain( + 'pnpm run test:e2e:terminal-rendering-golden' + ) + expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden') + expect(goldenRunSteps.get('windows')).toMatchObject({ + if: "runner.os == 'Windows'", + shell: 'pwsh' + }) + expect(goldenRunSteps.get('windows').run).toContain( + 'pnpm run test:e2e:windows-fresh-startup-golden' + ) expect(goldenWorkflow.on.pull_request).toBeUndefined() expect(goldenWorkflow.on.workflow_dispatch).toBeDefined() expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden') @@ -552,12 +573,18 @@ describe('Electron runtime package contract', () => { expect(publishReleaseNeeds).toContain('build') expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence') expect(releaseGoldenJob['continue-on-error']).toBeUndefined() - expect(releaseGoldenJob.strategy.matrix.include.map(({ platform }) => platform).sort()).toEqual( - goldenPlatforms - ) + expect(releaseGoldenMatrix).toEqual(goldenMatrix) expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain( 'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden' ) + const releaseWindowsRunStep = releaseGoldenJob.steps.find( + (step) => step.name === 'Run fresh-startup golden on Windows' + ) + expect(releaseWindowsRunStep).toMatchObject({ + if: "runner.os == 'Windows'", + shell: 'pwsh' + }) + expect(releaseWindowsRunStep.run).toContain('pnpm run test:e2e:windows-fresh-startup-golden') expect(releaseEvidenceJob['continue-on-error']).toBe(true) expect( releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort() diff --git a/package.json b/package.json index 6a0e4f33814..c9cf4a72891 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs", diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts index f6dcf14471a..3213ee0210e 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts @@ -2,7 +2,10 @@ import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' +import { + installRemoteManagedAgentHooks, + REMOTE_MANAGED_HOOK_INSTALLER_AGENTS +} from './remote-managed-hook-installers' import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' const tempHomes: string[] = [] @@ -34,7 +37,10 @@ describe('managed-hook local filesystem', () => { it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => { const home = await createTempHome() const filesystem = createManagedHookLocalFilesystem() - const options = { grokHomeDir: join(home, '.grok') } + const options = { + grokHomeDir: join(home, '.grok'), + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS + } const cold = await installRemoteManagedAgentHooks(filesystem, home, options) const warm = await installRemoteManagedAgentHooks(filesystem, home, options) @@ -61,7 +67,8 @@ describe('managed-hook local filesystem', () => { await writeFile(claudeConfig, '{"hooks": }', 'utf8') const results = await installRemoteManagedAgentHooks(createManagedHookLocalFilesystem(), home, { - grokHomeDir: join(home, '.grok') + grokHomeDir: join(home, '.grok'), + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS }) expect(results).toHaveLength(14) diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts index 5422de4e692..e4c23e13959 100644 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ b/src/main/agent-hooks/managed-hook-runtime.test.ts @@ -1,3 +1,6 @@ +import { chmod, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeChildProcess from 'node:child_process' @@ -12,7 +15,9 @@ vi.mock('node:child_process', async (importOriginal) => { const { execFile } = await import('node:child_process') const execFileMock = vi.mocked(execFile) -const { resolveRelayGrokHome } = await import('./managed-hook-runtime') +const { execFile: actualExecFile } = + await vi.importActual('node:child_process') +const { installManagedHooks, resolveRelayGrokHome } = await import('./managed-hook-runtime') type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void @@ -32,10 +37,46 @@ function stubProbeFailure(error: Error): void { beforeEach(() => { execFileMock.mockReset() + // Why: `installManagedHooks` proves a skipped probe through the login-shell run log, so the + // probe must really spawn unless a case above stubs it. The mock loses `promisify.custom`, + // so the real `(error, stdout, stderr)` callback is reshaped into the `{ stdout, stderr }` + // that `promisify(execFile)` resolves in production. + execFileMock.mockImplementation(((...args: unknown[]) => { + const callback = args.at(-1) as ExecFileCallback + return (actualExecFile as (...callArgs: unknown[]) => unknown)( + ...args.slice(0, -1), + (error: Error | null, stdout: string, stderr: string) => callback(error, { stdout, stderr }) + ) + }) as unknown as typeof execFile) }) -afterEach(() => { +const tempHomes: string[] = [] +const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp' +const SHELL_NAME = 'login-shell' +const SHELL_RUNS_NAME = 'login-shell-runs' + +async function createTempHome(): Promise { + const home = await mkdtemp(join(tempRoot, 'orca-managed-hook-runtime-')) + tempHomes.push(home) + return home +} + +/** Login shell that records each invocation, so a skipped GROK_HOME probe is observable. */ +async function stubLoginShell(home: string): Promise { + const shell = join(home, SHELL_NAME) + await writeFile( + shell, + `#!/bin/sh\necho ran >> "${join(home, SHELL_RUNS_NAME)}"\nexit 0\n`, + 'utf8' + ) + await chmod(shell, 0o755) + vi.stubEnv('HOME', home) + vi.stubEnv('SHELL', shell) +} + +afterEach(async () => { vi.unstubAllEnvs() + await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) }) describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => { @@ -78,3 +119,42 @@ describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => { await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') }) }) + +describe.runIf(process.platform !== 'win32')('installManagedHooks', () => { + it.each([ + ['omitted', undefined], + ['empty', { agents: [] }] + ])( + 'writes nothing and runs no probe when the allowlist is %s (issue #11641)', + async (_label, options) => { + const home = await createTempHome() + await stubLoginShell(home) + + await expect(installManagedHooks(options)).resolves.toEqual({ installers: 0, errors: 0 }) + + // Why: no agent config home, no ~/.orca install lock, and no GROK_HOME login-shell probe. + expect(await readdir(home)).toEqual([SHELL_NAME]) + } + ) + + it('still rejects an aborted request rather than resolving an empty summary', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + installManagedHooks({ signal: controller.signal, agents: [] }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('installs only the allowlisted agent, under the install lock', async () => { + const home = await createTempHome() + await stubLoginShell(home) + + await expect(installManagedHooks({ agents: ['claude'] })).resolves.toEqual({ + installers: 1, + errors: 0 + }) + + expect((await readdir(home)).sort()).toEqual(['.claude', '.orca', SHELL_NAME, SHELL_RUNS_NAME]) + }) +}) diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index bdd7195b50d..e995467ee7d 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -79,6 +79,11 @@ export async function installManagedHooks(options?: { agents?: readonly AgentHookTarget[] }): Promise { options?.signal?.throwIfAborted() + // Why: empty/omitted allowlist fails closed before any home/host probes. + const agents = options?.agents ?? [] + if (agents.length === 0) { + return { installers: 0, errors: 0 } + } const home = homedir() const grokHomeDir = await resolveRelayGrokHome(home, options?.signal) options?.signal?.throwIfAborted() @@ -96,7 +101,7 @@ export async function installManagedHooks(options?: { { grokHomeDir, signal: options?.signal, - agents: options?.agents + agents } ) return { diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 2b2cfcdcf38..9ba01bc81c6 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -707,7 +707,9 @@ describe('remote hook service installers', () => { it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => { const { sftp } = createFakeSftp() - const results = await installRemoteManagedAgentHooks(sftp, '/home/dev') + const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', { + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS + }) const byAgent = new Map(results.map((r) => [r.agent, r.state])) expect(byAgent.get('droid')).toBe('installed') expect(byAgent.get('copilot')).toBe('installed') @@ -727,6 +729,19 @@ describe('remote hook service installers', () => { } }) + it('fails closed when the agent allowlist is omitted or empty (issue #11641)', async () => { + const { sftp, fs } = createFakeSftp() + + await expect(installRemoteManagedAgentHooks(sftp, '/home/dev')).resolves.toEqual([]) + await expect( + installRemoteManagedAgentHooks(sftp, '/home/dev', { agents: [] }) + ).resolves.toEqual([]) + + // Why: fake SFTP seeds '/' only; no agent config homes or files may appear. + expect([...fs.files.keys()]).toEqual([]) + expect([...fs.dirs]).toEqual(['/']) + }) + it('stops before the next installer when its relay request is cancelled', async () => { const controller = new AbortController() const claudeInstall = vi @@ -746,7 +761,10 @@ describe('remote hook service installers', () => { const { sftp } = createFakeSftp() await expect( - installRemoteManagedAgentHooks(sftp, '/home/dev', { signal: controller.signal }) + installRemoteManagedAgentHooks(sftp, '/home/dev', { + signal: controller.signal, + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS + }) ).rejects.toMatchObject({ name: 'AbortError' }) expect(claudeInstall).toHaveBeenCalledTimes(1) expect(openClaudeInstall).not.toHaveBeenCalled() diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 0fdf0c47da4..6471ec5cfb4 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -26,7 +26,8 @@ export type RemoteManagedHookInstallOptions = { /** Stops before starting the next installer when the owning relay request * is cancelled. Individual filesystem mutations remain atomic. */ signal?: AbortSignal - /** Positively detected and enabled agents allowed to mutate config. */ + /** Positively detected and enabled agents allowed to mutate config. + * Required for any install: omit/empty fails closed (no config mutation). */ agents?: readonly AgentHookTarget[] } @@ -81,10 +82,15 @@ export async function installRemoteManagedAgentHooks( remoteHome: string, options?: RemoteManagedHookInstallOptions ): Promise { + // Why: omit/empty allowlist must never mean "install every agent" — that + // recreates config homes for CLIs the user never installed (issue #11641). + const allowedAgents = new Set(options?.agents ?? []) + if (allowedAgents.size === 0) { + return [] + } const results: AgentHookInstallStatus[] = [] - const allowedAgents = options?.agents ? new Set(options.agents) : null for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) { - if (allowedAgents && !allowedAgents.has(agent)) { + if (!allowedAgents.has(agent)) { continue } // Why: relay requests can disappear during reconnect; do not start more diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 4d2ae9b7bb5..e6a3a727fb4 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -11,7 +11,10 @@ import { RelayDispatcher } from '../../relay/dispatcher' import { registerWslHookFsHandlers } from '../../relay/wsl-hook-fs-bridge' import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter' -import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' +import { + installRemoteManagedAgentHooks, + REMOTE_MANAGED_HOOK_INSTALLER_AGENTS +} from './remote-managed-hook-installers' import { WslHookRelayManager } from './wsl-hook-relay-manager' import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import { @@ -122,7 +125,9 @@ describe.skipIf(process.platform === 'win32')( it('runs the unchanged remote managed hook installers against a WSL guest home', async () => { const adapter = createWslHookSftpAdapter(harness.mux) - const results = await installRemoteManagedAgentHooks(adapter, home) + const results = await installRemoteManagedAgentHooks(adapter, home, { + agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS + }) expect(results.length).toBeGreaterThan(0) expect(results.every((r) => r.state !== 'error')).toBe(true) diff --git a/src/main/browser/browser-download-destination.test.ts b/src/main/browser/browser-download-destination.test.ts index 5db849c80e9..d3719bf1e4a 100644 --- a/src/main/browser/browser-download-destination.test.ts +++ b/src/main/browser/browser-download-destination.test.ts @@ -86,6 +86,40 @@ describe('BrowserDownloadDestinationReservations', () => { expect(macReservations.reserve('report.csv').filename).toBe('report (1).csv') }) + it('rewrites Windows reserved device basenames', () => { + const reservations = new BrowserDownloadDestinationReservations({ + downloadsPath, + pathExists: vi.fn(() => false), + platform: 'win32' + }) + + expect(reservations.reserve('CON').filename).toBe('_CON') + expect(reservations.reserve('con.txt').filename).toBe('_con.txt') + expect(reservations.reserve('NUL.tar.gz').filename).toBe('_NUL.tar.gz') + expect(reservations.reserve('AUX.json').filename).toBe('_AUX.json') + expect(reservations.reserve('PRN').filename).toBe('_PRN') + expect(reservations.reserve('CLOCK$.csv').filename).toBe('_CLOCK$.csv') + expect(reservations.reserve('CONIN$.txt').filename).toBe('_CONIN$.txt') + expect(reservations.reserve('CONOUT$.log').filename).toBe('_CONOUT$.log') + expect(reservations.reserve('COM1.log').filename).toBe('_COM1.log') + expect(reservations.reserve('COM¹.log').filename).toBe('_COM¹.log') + expect(reservations.reserve('LPT9.csv').filename).toBe('_LPT9.csv') + expect(reservations.reserve('LPT³.csv').filename).toBe('_LPT³.csv') + expect(reservations.reserve('COM10.log').filename).toBe('COM10.log') + expect(reservations.reserve('LPT0.csv').filename).toBe('LPT0.csv') + expect(reservations.reserve('CONSOLE.txt').filename).toBe('CONSOLE.txt') + }) + + it('preserves Windows device-like names on other platforms', () => { + const reservations = new BrowserDownloadDestinationReservations({ + downloadsPath, + pathExists: vi.fn(() => false), + platform: 'linux' + }) + + expect(reservations.reserve('CON.txt').filename).toBe('CON.txt') + }) + it('fails after bounded collision attempts', () => { const reservations = new BrowserDownloadDestinationReservations({ downloadsPath, diff --git a/src/main/browser/browser-download-destination.ts b/src/main/browser/browser-download-destination.ts index 1aa875f76ad..53e541c4a0d 100644 --- a/src/main/browser/browser-download-destination.ts +++ b/src/main/browser/browser-download-destination.ts @@ -5,6 +5,8 @@ import { app } from 'electron' const MAX_BROWSER_DOWNLOAD_COLLISION_ATTEMPTS = 1_000 const WINDOWS_RESERVED_FILENAME_CHARS = new Set(['<', '>', ':', '"', '|', '?', '*']) +const WINDOWS_RESERVED_FILENAME = + /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu export type BrowserDownloadDestination = { filename: string @@ -18,7 +20,7 @@ type BrowserDownloadDestinationOptions = { platform?: NodeJS.Platform } -function normalizeFilename(filename: string): string { +function normalizeFilename(filename: string, platform: NodeJS.Platform): string { // Normalize separators first so basename strips paths from any platform. const normalizedSeparators = filename.replace(/\\/g, '/') const rawBasename = path.posix.basename(normalizedSeparators).trim() @@ -32,7 +34,12 @@ function normalizeFilename(filename: string): string { .join('') .replace(/[. ]+$/g, '') .trim() - return safeName || 'download' + if (!safeName) { + return 'download' + } + return platform === 'win32' && WINDOWS_RESERVED_FILENAME.test(safeName) + ? `_${safeName}` + : safeName } function buildCollisionCandidate(filename: string, suffix: number): string { @@ -65,7 +72,7 @@ export class BrowserDownloadDestinationReservations { } reserve(filename: string): BrowserDownloadDestination { - const safeFilename = normalizeFilename(filename) + const safeFilename = normalizeFilename(filename, this.platform) const downloadsPath = this.downloadsPath() for (let attempt = 0; attempt < MAX_BROWSER_DOWNLOAD_COLLISION_ATTEMPTS; attempt += 1) { diff --git a/tests/e2e/golden-windows-fresh-startup.spec.ts b/tests/e2e/golden-windows-fresh-startup.spec.ts new file mode 100644 index 00000000000..f153f765dcd --- /dev/null +++ b/tests/e2e/golden-windows-fresh-startup.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from './helpers/orca-app' + +test.use({ dismissOnboarding: false, seedTestRepo: false }) +test.skip(process.platform !== 'win32', 'Fresh-profile fsync regression is Windows-only') + +test('fresh Windows profile reaches onboarding @windows-fresh-startup-golden', async ({ + orcaPage +}) => { + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 30_000 + }) +}) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index b083e37f36d..434a3297df3 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -252,6 +252,7 @@ export const test = base.extend({ ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) } }) + forwardElectronProcessLogs(app, testInfo) try { const resolvedHome = await app.evaluate(({ app }) => app.getPath('home')) assertElectronResolvedIsolatedHome(resolvedHome, homeIsolation) @@ -261,7 +262,6 @@ export const test = base.extend({ await removeUserDataDirAfterShutdown(userDataDir) throw error } - forwardElectronProcessLogs(app, testInfo) await provideFixture(app) // Why: the Playwright close promise can settle before all Electron and PTY // descendants are gone in CI; worker teardown then hangs on open handles.