mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 12:07:58 +00:00
test: remove 260 low-value tests (#34535)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -1407,9 +1407,8 @@
|
||||
1 test-timeout-needs-reason:src/shared/check-job-log-text-performance.test.ts
|
||||
1 test-timeout-needs-reason:src/shared/dsh-real-cli.test.ts
|
||||
1 test-timeout-needs-reason:src/shared/setup-agent-sequencing.windows.test.ts
|
||||
1 test-timeout-needs-reason:src/shared/terminal-links.test.ts
|
||||
2 test-timeout-needs-reason:src/shared/workspace-space-entry-traversal.test.ts
|
||||
2 test-timeout-needs-reason:src/shared/wsl-login-shell-command.test.ts
|
||||
1 test-timeout-needs-reason:src/shared/wsl-login-shell-command.test.ts
|
||||
1 test-timeout-needs-reason:src/wsl-guest/agent-hook-server.test.ts
|
||||
1 test-timeout-needs-reason:tests/e2e/active-view-restart-restore.spec.ts
|
||||
1 test-timeout-needs-reason:tests/e2e/agent-send-picker-reveal.spec.ts
|
||||
|
||||
@@ -13059,7 +13059,6 @@
|
||||
"src/shared/windows-cmd-runner-delayed-launch.test.ts": 34,
|
||||
"src/shared/windows-command-line-budget.test.ts": 258,
|
||||
"src/shared/windows-console-input.test.ts": 54,
|
||||
"src/shared/windows-environment-expansion.test.ts": 79,
|
||||
"src/shared/windows-interactive-login-spawn.test.ts": 38,
|
||||
"src/shared/windows-long-path-git-args.test.ts": 29,
|
||||
"src/shared/windows-path-access.test.ts": 35,
|
||||
|
||||
@@ -3,18 +3,13 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDefaultRepoHookSettings } from './constants'
|
||||
import {
|
||||
createSequencedSetupAgentCommands,
|
||||
createSetupAgentSequenceNonce,
|
||||
SETUP_COMPLETE_MESSAGE,
|
||||
resolveSetupAgentSequenceLaunchCommand,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV
|
||||
} from './setup-agent-sequencing'
|
||||
import { shouldWaitForSetupBeforeAgentStartup } from './setup-agent-startup-policy'
|
||||
|
||||
const TEMP_DIRS: string[] = []
|
||||
const WINDOWS_PROCESS_TEST_TIMEOUT_MS = 30_000
|
||||
@@ -26,127 +21,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('createSequencedSetupAgentCommands', () => {
|
||||
it('defaults agent startup to immediate unless the wait policy is explicit', () => {
|
||||
expect(getDefaultRepoHookSettings().setupAgentStartupPolicy).toBe('start-immediately')
|
||||
expect(shouldWaitForSetupBeforeAgentStartup(undefined)).toBe(false)
|
||||
expect(shouldWaitForSetupBeforeAgentStartup('start-immediately')).toBe(false)
|
||||
expect(shouldWaitForSetupBeforeAgentStartup('wait-for-setup')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the original sequenced startup command as the launch hint when present', () => {
|
||||
expect(
|
||||
resolveSetupAgentSequenceLaunchCommand(
|
||||
{ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' },
|
||||
'powershell wait-wrapper'
|
||||
)
|
||||
).toBe('omp --resume')
|
||||
expect(
|
||||
resolveSetupAgentSequenceLaunchCommand(
|
||||
{ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: ' ' },
|
||||
'powershell wait-wrapper'
|
||||
)
|
||||
).toBe('powershell wait-wrapper')
|
||||
})
|
||||
|
||||
it('wraps POSIX setup and startup commands with a matching nonce marker', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: '/repo/.git/orca/setup-runner.sh',
|
||||
startupCommand: "codex 'fix bug'",
|
||||
platform: 'posix',
|
||||
nonce: 'nonce-123',
|
||||
waitTimeoutSeconds: 9
|
||||
})
|
||||
|
||||
expect(result.setupCommand).toMatch(/^bash -lc /)
|
||||
expect(result.setupCommand).toContain('bash /repo/.git/orca/setup-runner.sh')
|
||||
expect(result.setupCommand).toContain('printf')
|
||||
expect(result.setupCommand).toContain('nonce-123 "$status"')
|
||||
expect(result.setupCommand).toContain(
|
||||
'mv -f /repo/.git/orca/setup-runner.sh.nonce-123.done.tmp'
|
||||
)
|
||||
// Pair-inserting line editors (zsh-autopair) corrupt typed brackets (#18059).
|
||||
expect(result.setupCommand).not.toMatch(/[()[\]{}]/)
|
||||
const startupScript = result.startupEnv?.[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]
|
||||
expect(result.startupCommand).toBe(
|
||||
`bash -lc 'eval "$${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}"'`
|
||||
)
|
||||
expect(startupScript).toContain('deadline=$((SECONDS + 9))')
|
||||
expect(startupScript).not.toContain('date +%s')
|
||||
expect(startupScript).toContain('Waiting for setup to finish before starting agent...')
|
||||
expect(startupScript).toContain('[ "$seen" = nonce-123 ]')
|
||||
expect(startupScript).toContain(
|
||||
'rm -f /repo/.git/orca/setup-runner.sh.nonce-123.done /repo/.git/orca/setup-runner.sh.nonce-123.done.tmp'
|
||||
)
|
||||
expect(startupScript).toContain('exec codex')
|
||||
expect(startupScript).toContain('fix bug')
|
||||
expect(result.startupEnv).toEqual(
|
||||
expect.objectContaining({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex 'fix bug'",
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: startupScript
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('announces success so the pane stops showing the waiting line', () => {
|
||||
const commands = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: '/repo/.git/orca/setup-runner.sh',
|
||||
startupCommand: 'codex',
|
||||
platform: 'posix',
|
||||
nonce: 'nonce-1'
|
||||
})
|
||||
const script = commands.startupEnv?.ORCA_SEQUENCED_STARTUP_SCRIPT ?? ''
|
||||
// Why ordering: `eval`/`exec` never returns, so a later message never renders.
|
||||
expect(script.indexOf(SETUP_COMPLETE_MESSAGE)).toBeGreaterThan(-1)
|
||||
expect(script.indexOf(SETUP_COMPLETE_MESSAGE)).toBeLessThan(
|
||||
script.indexOf('eval "$ORCA_SEQUENCED_STARTUP_COMMAND"')
|
||||
)
|
||||
})
|
||||
|
||||
it('announces success on the native Windows gate too', () => {
|
||||
const commands = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd',
|
||||
platform: 'windows',
|
||||
startupCommand: 'codex',
|
||||
nonce: 'nonce-2'
|
||||
})
|
||||
const decoded = Buffer.from(
|
||||
commands.startupCommand.split('-EncodedCommand ')[1] ?? '',
|
||||
'base64'
|
||||
).toString('utf16le')
|
||||
expect(decoded).toContain(SETUP_COMPLETE_MESSAGE)
|
||||
expect(decoded.indexOf(SETUP_COMPLETE_MESSAGE)).toBeLessThan(
|
||||
decoded.indexOf('Invoke-Expression $startup')
|
||||
)
|
||||
})
|
||||
|
||||
it('doubles typographic single quotes in native Windows gate literals', () => {
|
||||
const commands = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: 'C:\\O\u2019Brien\\.git\\orca\\setup-runner.cmd',
|
||||
platform: 'windows',
|
||||
startupCommand: 'codex',
|
||||
nonce: 'nonce-3'
|
||||
})
|
||||
const decoded = Buffer.from(
|
||||
commands.setupCommand.split('-EncodedCommand ')[1] ?? '',
|
||||
'base64'
|
||||
).toString('utf16le')
|
||||
expect(decoded).toContain("$runner = 'C:\\O\u2019\u2019Brien\\.git\\orca\\setup-runner.cmd'")
|
||||
})
|
||||
|
||||
it('leaves the failure and timeout messages as the only other outcomes', () => {
|
||||
const script =
|
||||
createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: '/repo/.git/orca/setup-runner.sh',
|
||||
startupCommand: 'codex',
|
||||
platform: 'posix',
|
||||
nonce: 'nonce-3'
|
||||
}).startupEnv?.ORCA_SEQUENCED_STARTUP_SCRIPT ?? ''
|
||||
// Silence on success is what made a healthy worktree look stuck.
|
||||
expect(script).toContain(SETUP_COMPLETE_MESSAGE)
|
||||
expect(script).toContain('Setup failed; skipping agent startup.')
|
||||
expect(script).toContain('Timed out waiting for setup before starting agent.')
|
||||
})
|
||||
|
||||
it('keeps the POSIX terminal submission below the canonical input floor', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: `/repo/${'nested-worktree/'.repeat(100)}setup-runner.sh`,
|
||||
@@ -162,79 +36,6 @@ describe('createSequencedSetupAgentCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses launch-specific marker paths for overlapping setup gates', () => {
|
||||
const first = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: '/repo/.git/orca/setup-runner.sh',
|
||||
startupCommand: 'claude',
|
||||
platform: 'posix',
|
||||
nonce: 'first-launch'
|
||||
})
|
||||
const second = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: '/repo/.git/orca/setup-runner.sh',
|
||||
startupCommand: 'codex',
|
||||
platform: 'posix',
|
||||
nonce: 'second-launch'
|
||||
})
|
||||
|
||||
expect(first.setupCommand).toContain('/repo/.git/orca/setup-runner.sh.first-launch.done')
|
||||
expect(first.startupEnv?.[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]).toContain(
|
||||
'/repo/.git/orca/setup-runner.sh.first-launch.done'
|
||||
)
|
||||
expect(second.setupCommand).toContain('/repo/.git/orca/setup-runner.sh.second-launch.done')
|
||||
expect(second.startupEnv?.[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]).toContain(
|
||||
'/repo/.git/orca/setup-runner.sh.second-launch.done'
|
||||
)
|
||||
expect(first.setupCommand).not.toContain('/repo/.git/orca/setup-runner.sh.second-launch.done')
|
||||
expect(second.setupCommand).not.toContain('/repo/.git/orca/setup-runner.sh.first-launch.done')
|
||||
})
|
||||
|
||||
it('keeps simple POSIX startup commands eligible for exec when quoted text has separators', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: '/repo/.git/orca/setup-runner.sh',
|
||||
startupCommand: "codex 'fix this; then test'",
|
||||
platform: 'posix',
|
||||
nonce: 'nonce-quoted',
|
||||
waitTimeoutSeconds: 9
|
||||
})
|
||||
|
||||
const startupScript = result.startupEnv?.[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]
|
||||
expect(startupScript).toContain("exec codex 'fix this; then test'")
|
||||
expect(startupScript).not.toContain('eval codex')
|
||||
})
|
||||
|
||||
it('preserves POSIX inline environment assignment startup commands', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: '/repo/.git/orca/setup-runner.sh',
|
||||
startupCommand: 'FOO=bar claude',
|
||||
platform: 'posix',
|
||||
nonce: 'nonce-env',
|
||||
waitTimeoutSeconds: 9
|
||||
})
|
||||
|
||||
const startupScript = result.startupEnv?.[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]
|
||||
expect(startupScript).toContain('FOO=bar claude')
|
||||
expect(startupScript).toContain('exit "$?"')
|
||||
expect(startupScript).not.toContain('exec FOO=bar claude')
|
||||
})
|
||||
|
||||
it('uses the converted Linux marker path for WSL UNC runners on Windows', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath:
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
|
||||
startupCommand: 'claude',
|
||||
platform: 'windows',
|
||||
nonce: 'nonce-wsl'
|
||||
})
|
||||
|
||||
expect(result.setupCommand).toContain(
|
||||
'bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh'
|
||||
)
|
||||
expect(result.setupCommand).toContain(
|
||||
'/home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh.nonce-wsl.done'
|
||||
)
|
||||
expect(result.setupCommand).not.toContain('wsl.localhost')
|
||||
})
|
||||
|
||||
it('keeps remote POSIX runners in bash even from a Windows client', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: '/remote/repo/.git/worktrees/feature/orca/setup-runner.sh',
|
||||
@@ -269,60 +70,6 @@ describe('createSequencedSetupAgentCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps native Windows runners in a cmd-pinned setup and startup gate', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd',
|
||||
startupCommand: "codex --model gpt-5 'fix !PATH! & test'",
|
||||
platform: 'windows',
|
||||
nonce: 'nonce-win',
|
||||
waitTimeoutSeconds: 3
|
||||
})
|
||||
const setupPowerShell = decodePowerShellScript(result.setupCommand)
|
||||
const startupPowerShell = decodePowerShellScript(result.startupCommand)
|
||||
|
||||
expect(result.setupCommand).toContain(
|
||||
'powershell.exe -NoProfile -NonInteractive -EncodedCommand'
|
||||
)
|
||||
expect(setupPowerShell).toContain("$runner = 'C:\\repo\\.git\\orca\\setup-runner.cmd'")
|
||||
expect(setupPowerShell).toContain('$nonce + ":" + $setupStatus')
|
||||
expect(result.startupCommand.match(/powershell\.exe/g)).toHaveLength(1)
|
||||
expect(result.startupCommand).toContain(
|
||||
'powershell.exe -NoProfile -NonInteractive -EncodedCommand'
|
||||
)
|
||||
expect(startupPowerShell).toContain('AddSeconds(3)')
|
||||
expect(startupPowerShell).toContain('Missing setup marker path.')
|
||||
expect(startupPowerShell).toContain('Timed out waiting for setup before starting agent.')
|
||||
expect(startupPowerShell).toContain('Setup failed; skipping agent startup.')
|
||||
expect(startupPowerShell).toContain(
|
||||
'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue'
|
||||
)
|
||||
expect(result.startupCommand).not.toContain('%ERRORLEVEL%')
|
||||
expect(startupPowerShell).toContain('Invoke-Expression')
|
||||
expect(result.startupCommand).not.toContain('fix !PATH! & test')
|
||||
expect(result.startupEnv).toEqual({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --model gpt-5 'fix !PATH! & test'"
|
||||
})
|
||||
// EncodedCommand is not policy-gated; command-line ExecutionPolicy Bypass was a no-op with an EDR-heavy shape.
|
||||
// Keep base64 because terminal panes reparse these strings.
|
||||
expect(result.setupCommand).not.toMatch(/-ExecutionPolicy/i)
|
||||
expect(result.startupCommand).not.toMatch(/-ExecutionPolicy/i)
|
||||
// User startup may invoke policy-gated .ps1; move relief inside the payload instead of deleting it.
|
||||
expect(startupPowerShell).toContain(
|
||||
'Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction Stop'
|
||||
)
|
||||
// Stop routes autoload failures (e.g. duplicate PS5.1 type data) to a reporting catch.
|
||||
// Report without rethrowing so broken policy cmdlets cannot abort startup.
|
||||
expect(startupPowerShell).not.toContain('catch {}')
|
||||
expect(startupPowerShell).toMatch(/catch \{ \[Console\]::Error\.WriteLine\(/)
|
||||
expect(startupPowerShell).toContain('$_.FullyQualifiedErrorId')
|
||||
expect(startupPowerShell).not.toMatch(/catch \{[^}]*throw/)
|
||||
// Why: the autoloaded module's progress record would otherwise corrupt this gate's stderr.
|
||||
expect(startupPowerShell).toContain("$ProgressPreference = 'SilentlyContinue'")
|
||||
expect(startupPowerShell).toContain('$ProgressPreference = $orcaProgress')
|
||||
// The setup gate only ever launches a .cmd/.bat runner, so it needs no relief.
|
||||
expect(setupPowerShell).not.toMatch(/Set-ExecutionPolicy/i)
|
||||
})
|
||||
|
||||
it('launches a batch runner through the cmd launcher inside a Git Bash gate', () => {
|
||||
// Batch setup stays a cmd runner inside Git Bash; the gate remains bash to parse the already-POSIX-quoted startup command (#6896).
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
@@ -584,17 +331,6 @@ describe('createSequencedSetupAgentCommands', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('createSetupAgentSequenceNonce', () => {
|
||||
it('prefers crypto.randomUUID when available', () => {
|
||||
const originalCrypto = globalThis.crypto
|
||||
vi.stubGlobal('crypto', { randomUUID: () => 'uuid-1' })
|
||||
|
||||
expect(createSetupAgentSequenceNonce()).toBe('uuid-1')
|
||||
|
||||
vi.stubGlobal('crypto', originalCrypto)
|
||||
})
|
||||
})
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-setup-sequencing-'))
|
||||
TEMP_DIRS.push(dir)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSetupRunnerCommand,
|
||||
getSetupRunnerCommandPlatformForPath,
|
||||
nativeWindowsPathToPosixShellPath,
|
||||
resolveSetupRunnerCommand
|
||||
} from './setup-runner-command'
|
||||
import { buildSetupRunnerCommand } from './setup-runner-command'
|
||||
|
||||
describe('buildSetupRunnerCommand', () => {
|
||||
it('uses bash for WSL UNC runner scripts regardless of host casing', () => {
|
||||
@@ -16,29 +11,12 @@ describe('buildSetupRunnerCommand', () => {
|
||||
).toBe('bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh')
|
||||
})
|
||||
|
||||
it('uses bash with Linux paths for forward-slash WSL UNC runner scripts', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand(
|
||||
'//wsl.localhost/Ubuntu/home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh',
|
||||
'windows'
|
||||
)
|
||||
).toBe('bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh')
|
||||
})
|
||||
|
||||
it('keeps generic forward-slash UNC runner scripts on cmd.exe', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('//server/share/repo/.git/orca/setup-runner.cmd', 'windows')
|
||||
).toBe('cmd.exe /c "//server/share/repo/.git/orca/setup-runner.cmd"')
|
||||
})
|
||||
|
||||
it('uses POSIX launch semantics for native Windows runners when the setup shell is POSIX', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', {
|
||||
family: 'posix'
|
||||
})
|
||||
).toBe('bash /c/repo/.git/orca/setup-runner.sh')
|
||||
})
|
||||
|
||||
it('uses the active WSL shell with WSL paths for native Windows POSIX runners', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', {
|
||||
@@ -48,30 +26,12 @@ describe('buildSetupRunnerCommand', () => {
|
||||
).toBe('bash /mnt/c/repo/.git/orca/setup-runner.sh')
|
||||
})
|
||||
|
||||
it('keeps cmd.exe launch semantics for cmd setup runners', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', {
|
||||
family: 'cmd'
|
||||
})
|
||||
).toBe('cmd.exe /c "C:\\repo\\.git\\orca\\setup-runner.cmd"')
|
||||
})
|
||||
|
||||
it('infers generated POSIX runner shell semantics from extension when metadata is absent', () => {
|
||||
expect(buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows')).toBe(
|
||||
'bash /c/repo/.git/orca/setup-runner.sh'
|
||||
)
|
||||
})
|
||||
|
||||
it('never hands a batch runner to bash, even from a Git Bash pane', () => {
|
||||
// Runner syntax follows its file, not the terminal: POSIX panes still execute batch scripts through cmd.
|
||||
const command = buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', {
|
||||
family: 'posix'
|
||||
})
|
||||
|
||||
expect(command).not.toContain('bash ')
|
||||
expect(command).not.toContain('/c/repo')
|
||||
})
|
||||
|
||||
it('avoids the bare /c switch when a POSIX pane launches a batch runner', () => {
|
||||
// MSYS rewrites bare cmd.exe /c into a drive path, leaving an interactive cmd instead of executing (#6896).
|
||||
const command = buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', {
|
||||
@@ -83,18 +43,6 @@ describe('buildSetupRunnerCommand', () => {
|
||||
/^powershell\.exe -NoProfile -NonInteractive -EncodedCommand [A-Za-z0-9+/=]+$/
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the batch runner path in native form for a POSIX pane launch', () => {
|
||||
// cmd cannot read /c/... MSYS paths; retain native runner spelling, also used for markers.
|
||||
expect(
|
||||
resolveSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', {
|
||||
family: 'posix'
|
||||
})
|
||||
).toMatchObject({
|
||||
runnerScriptPathForShell: 'C:\\repo\\.git\\orca\\setup-runner.cmd',
|
||||
shell: 'windows'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSetupRunnerCommand cmd metacharacter guard', () => {
|
||||
@@ -110,30 +58,6 @@ describe('buildSetupRunnerCommand cmd metacharacter guard', () => {
|
||||
return decoded
|
||||
}
|
||||
|
||||
it.each(['%', '&', '|', '<', '>', '^', '(', ')', '!', ',', ';', '=', '$', '`'])(
|
||||
'hardens the launch when the runner path contains %s',
|
||||
(character) => {
|
||||
const command = buildSetupRunnerCommand(cmdRunner(`\\a${character}b`), 'windows', {
|
||||
family: 'cmd'
|
||||
})
|
||||
|
||||
expect(command).toMatch(
|
||||
/^powershell\.exe -NoProfile -NonInteractive -EncodedCommand [A-Za-z0-9+/=]+$/
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['plain', 'C:\\repo\\.git\\orca\\setup-runner.cmd'],
|
||||
['spaces', 'C:\\Program Files\\repo\\.git\\orca\\setup-runner.cmd'],
|
||||
['single quote', "C:\\o'brien\\.git\\orca\\setup-runner.cmd"],
|
||||
['brackets and dash', 'C:\\repo-[2]\\.git\\orca\\setup-runner.cmd']
|
||||
])('keeps the plain cmd launch for a %s path', (_label, runnerScriptPath) => {
|
||||
expect(buildSetupRunnerCommand(runnerScriptPath, 'windows', { family: 'cmd' })).toBe(
|
||||
`cmd.exe /c "${runnerScriptPath}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the runner path through the environment rather than the cmd argument string', () => {
|
||||
const runnerScriptPath = cmdRunner('\\100%%\\a&b')
|
||||
const script = decodePowerShellCommand(
|
||||
@@ -158,24 +82,6 @@ describe('buildSetupRunnerCommand cmd metacharacter guard', () => {
|
||||
expect(script).toContain("$runner = 'C:\\o''brien&co\\.git\\orca\\setup-runner.cmd'")
|
||||
})
|
||||
|
||||
it('leaves runnerScriptPathForShell untouched so marker paths keep the native form', () => {
|
||||
const runnerScriptPath = cmdRunner('\\a&b')
|
||||
|
||||
expect(resolveSetupRunnerCommand(runnerScriptPath, 'windows', { family: 'cmd' })).toMatchObject(
|
||||
{
|
||||
runnerScriptPathForShell: runnerScriptPath,
|
||||
shell: 'windows'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['native POSIX runner', 'C:\\repo\\a&b\\.git\\orca\\setup-runner.sh', undefined],
|
||||
['WSL UNC runner', '\\\\wsl.localhost\\Ubuntu\\home\\a&b\\orca\\setup-runner.sh', undefined]
|
||||
])('does not disturb the %s launch', (_label, runnerScriptPath) => {
|
||||
expect(buildSetupRunnerCommand(runnerScriptPath, 'windows')).toMatch(/^bash /)
|
||||
})
|
||||
|
||||
it('does not disturb the wsl.exe POSIX launch', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('C:\\repo\\a&b\\.git\\orca\\setup-runner.sh', 'windows', {
|
||||
@@ -185,52 +91,3 @@ describe('buildSetupRunnerCommand cmd metacharacter guard', () => {
|
||||
).toBe("bash '/mnt/c/repo/a&b/.git/orca/setup-runner.sh'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeWindowsPathToPosixShellPath', () => {
|
||||
it('converts a drive path to the MSYS form Git Bash uses', () => {
|
||||
expect(nativeWindowsPathToPosixShellPath('C:\\Users\\jin\\repo')).toBe('/c/Users/jin/repo')
|
||||
})
|
||||
|
||||
it('is idempotent, so a double-applied conversion cannot corrupt a value', () => {
|
||||
const once = nativeWindowsPathToPosixShellPath('D:\\repo\\worktrees\\feature')
|
||||
expect(nativeWindowsPathToPosixShellPath(once)).toBe(once)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSetupRunnerCommandPlatformForPath', () => {
|
||||
it('prefers POSIX for absolute POSIX runner paths even from Windows clients', () => {
|
||||
expect(
|
||||
getSetupRunnerCommandPlatformForPath('/remote/repo/.git/orca/setup-runner.sh', 'windows')
|
||||
).toBe('posix')
|
||||
})
|
||||
|
||||
it('prefers Windows for native Windows runner paths even from POSIX clients', () => {
|
||||
expect(
|
||||
getSetupRunnerCommandPlatformForPath('C:\\repo\\.git\\orca\\setup-runner.cmd', 'posix')
|
||||
).toBe('windows')
|
||||
})
|
||||
|
||||
it('keeps WSL UNC paths on the Windows resolver so they can be converted', () => {
|
||||
expect(
|
||||
getSetupRunnerCommandPlatformForPath(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\orca\\setup-runner.sh',
|
||||
'posix'
|
||||
)
|
||||
).toBe('windows')
|
||||
})
|
||||
|
||||
it('keeps forward-slash UNC paths on the Windows resolver', () => {
|
||||
expect(
|
||||
getSetupRunnerCommandPlatformForPath(
|
||||
'//wsl.localhost/Ubuntu/home/jin/repo/.git/orca/setup-runner.sh',
|
||||
'posix'
|
||||
)
|
||||
).toBe('windows')
|
||||
expect(
|
||||
getSetupRunnerCommandPlatformForPath(
|
||||
'//server/share/repo/.git/orca/setup-runner.cmd',
|
||||
'posix'
|
||||
)
|
||||
).toBe('windows')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,29 +121,6 @@ describe('inspectSetupScriptImportCandidates', () => {
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('imports setup and teardown commands from Superset config', async () => {
|
||||
const candidates = await inspectSetupScriptImportCandidates(
|
||||
makeReader({
|
||||
'.superset/config.json': JSON.stringify({
|
||||
setup: ['./.superset/setup.sh', 'bun install'],
|
||||
teardown: ['./.superset/teardown.sh'],
|
||||
run: ['bun dev']
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
provider: 'superset',
|
||||
label: 'Superset',
|
||||
files: ['.superset/config.json'],
|
||||
setup: './.superset/setup.sh\nbun install',
|
||||
archive: './.superset/teardown.sh',
|
||||
unsupportedFields: ['run']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('applies Superset local before and after setup overlays', async () => {
|
||||
const candidates = await inspectSetupScriptImportCandidates(
|
||||
makeReader({
|
||||
@@ -175,34 +152,6 @@ describe('inspectSetupScriptImportCandidates', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reports unsupported Superset local script object fields', async () => {
|
||||
const candidates = await inspectSetupScriptImportCandidates(
|
||||
makeReader({
|
||||
'.superset/config.json': JSON.stringify({
|
||||
setup: ['bun install']
|
||||
}),
|
||||
'.superset/config.local.json': JSON.stringify({
|
||||
setup: {
|
||||
before: ['corepack enable'],
|
||||
after: ['bun run db:migrate'],
|
||||
cwd: 'packages/web'
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
provider: 'superset',
|
||||
label: 'Superset',
|
||||
files: ['.superset/config.json', '.superset/config.local.json'],
|
||||
setup: 'corepack enable\nbun install\nbun run db:migrate',
|
||||
archive: undefined,
|
||||
unsupportedFields: ['config.local.setup.cwd']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('imports setup commands from cmux project config', async () => {
|
||||
const candidates = await inspectSetupScriptImportCandidates(
|
||||
makeReader({
|
||||
@@ -237,58 +186,6 @@ describe('inspectSetupScriptImportCandidates', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('imports setup commands from root cmux config when project config is absent', async () => {
|
||||
const candidates = await inspectSetupScriptImportCandidates(
|
||||
makeReader({
|
||||
'cmux.json': JSON.stringify({
|
||||
commands: [
|
||||
{
|
||||
title: 'Workspace Setup',
|
||||
keywords: ['setup'],
|
||||
command: 'pnpm install'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
provider: 'cmux',
|
||||
label: 'cmux',
|
||||
files: ['cmux.json'],
|
||||
setup: 'pnpm install',
|
||||
unsupportedFields: []
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('imports setup and archive commands from Conductor config', async () => {
|
||||
const candidates = await inspectSetupScriptImportCandidates(
|
||||
makeReader({
|
||||
'conductor.json': JSON.stringify({
|
||||
scripts: {
|
||||
setup: 'pnpm install',
|
||||
archive: 'pnpm clean',
|
||||
run: 'pnpm dev'
|
||||
},
|
||||
runScriptMode: 'manual'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
provider: 'conductor',
|
||||
label: 'Conductor',
|
||||
files: ['conductor.json'],
|
||||
setup: 'pnpm install',
|
||||
archive: 'pnpm clean',
|
||||
unsupportedFields: ['runScriptMode', 'scripts.run']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('imports setup and cleanup scripts from Codex environment config', async () => {
|
||||
const candidates = await inspectSetupScriptImportCandidates(
|
||||
makeReader({
|
||||
|
||||
@@ -5,19 +5,12 @@ import { basename, dirname, join } from 'node:path'
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseDarwinExecutablePath,
|
||||
readShellProcessReadiness,
|
||||
resolveInstalledShellExecutablePaths,
|
||||
resolveShellExecutablePath
|
||||
} from './shell-process-readiness'
|
||||
|
||||
describe('shell process readiness', () => {
|
||||
it('extracts the primary text image from macOS lsof output', () => {
|
||||
expect(parseDarwinExecutablePath('p42\nftxt\nn/bin/zsh\nftxt\nn/usr/lib/zsh/zle.so\n')).toBe(
|
||||
'/bin/zsh'
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'reads a child process executable path from real lsof on macOS',
|
||||
async () => {
|
||||
@@ -125,24 +118,4 @@ describe('shell process readiness', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'omits a same-name executable that no PATH entry reaches',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-offpath-shell-'))
|
||||
const onPath = join(root, 'bin')
|
||||
const offPath = join(root, 'dropped')
|
||||
await mkdir(onPath)
|
||||
await mkdir(offPath)
|
||||
await symlink(process.execPath, join(onPath, 'shell-name'))
|
||||
await symlink(process.execPath, join(offPath, 'shell-name'))
|
||||
try {
|
||||
await expect(
|
||||
resolveInstalledShellExecutablePaths('shell-name', root, onPath)
|
||||
).resolves.not.toContain(join(offPath, 'shell-name'))
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
STAGED_STARTUP_COMMAND_STALE_MS,
|
||||
discardStagedStartupCommand,
|
||||
stageStartupCommand,
|
||||
startupStagingFailureNotice,
|
||||
sweepStaleStagedStartupCommands
|
||||
} from './startup-command-staging'
|
||||
|
||||
@@ -33,11 +32,6 @@ function stage(command: string, shellPath = '/bin/zsh', platform: NodeJS.Platfor
|
||||
}
|
||||
|
||||
describe('stageStartupCommand', () => {
|
||||
it('types a short single-line command as is', () => {
|
||||
expect(stage(`claude 'fix it'`)).toEqual({ command: `claude 'fix it'`, delivery: 'typed' })
|
||||
expect(readdirSync(directory)).toEqual([])
|
||||
})
|
||||
|
||||
it('types exactly 512 bytes, stages 513', () => {
|
||||
expect(stage('a'.repeat(512)).delivery).toBe('typed')
|
||||
expect(stage('a'.repeat(513)).delivery).toBe('staged')
|
||||
@@ -70,15 +64,6 @@ describe('stageStartupCommand', () => {
|
||||
expect(readFileSync(scriptPath, 'utf8')).toBe(`command rm -f -- '${scriptPath}'\n${command}\n`)
|
||||
})
|
||||
|
||||
it('evals the script in fish, which runs a sourced file without job control', () => {
|
||||
const staged = stage('a'.repeat(600), '/opt/homebrew/bin/fish')
|
||||
expect(staged.command).toBe(`eval (string collect < '${staged.scriptPath}')`)
|
||||
})
|
||||
|
||||
it('recognizes a login shell name', () => {
|
||||
expect(stage('a'.repeat(600), '-zsh').delivery).toBe('staged')
|
||||
})
|
||||
|
||||
// Why: ksh runs a sourced file's commands in the shell's own process group, so Ctrl-Z is lost.
|
||||
it.each([['/bin/ksh'], ['/usr/bin/mksh']])(
|
||||
'runs a long Orca-built line through /bin/sh in %s, and types a short one as is',
|
||||
@@ -113,22 +98,6 @@ describe('stageStartupCommand', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Why: typed raw, tcsh runs a prompt's second line as a command and MAX_CANON cuts a long one.
|
||||
it.each([['/usr/bin/nu'], ['/bin/tcsh'], ['/usr/local/bin/pwsh'], [undefined]])(
|
||||
'runs an Orca-built launch line through /bin/sh for a shell that cannot source it (%s)',
|
||||
(shellPath) => {
|
||||
const staged = stageStartupCommand({
|
||||
command: `claude 'one\ntwo'`,
|
||||
shellPath,
|
||||
orcaBuiltLine: true,
|
||||
platform: 'darwin',
|
||||
directory
|
||||
})
|
||||
expect(staged.delivery).toBe('staged')
|
||||
expect(staged.command).toBe(`/bin/sh '${staged.scriptPath}'`)
|
||||
}
|
||||
)
|
||||
|
||||
// Measured: tcsh typed `'fix C:'"\\"'dir'` as `fix C:\\dir` and expanded `!!` in `'wow!! great'`.
|
||||
it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])(
|
||||
'stages even a short Orca-built line for a shell that cannot source it (%s)',
|
||||
@@ -178,13 +147,6 @@ describe('stageStartupCommand', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('types a short Orca-built line in a shell that reads its quoting literally', () => {
|
||||
const command = `claude 'wow!! great'`
|
||||
expect(
|
||||
stageStartupCommand({ command, shellPath: '/bin/zsh', orcaBuiltLine: true, directory })
|
||||
).toEqual({ command, delivery: 'typed' })
|
||||
})
|
||||
|
||||
// Why: a quick command or --command written for tcsh or nu is not sh syntax.
|
||||
it.each([['/bin/tcsh'], ['/usr/bin/nu'], [undefined]])(
|
||||
'types a command the user wrote as it always was in a shell that cannot source it (%s)',
|
||||
@@ -196,13 +158,6 @@ describe('stageStartupCommand', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves Windows hosts alone', () => {
|
||||
expect(stage('a'.repeat(600), 'C:\\Program Files\\Git\\bin\\bash.exe', 'win32').delivery).toBe(
|
||||
'typed'
|
||||
)
|
||||
expect(stage('a'.repeat(600), '/bin/bash', 'win32').delivery).toBe('typed')
|
||||
})
|
||||
|
||||
it('types the full command and says so when the script cannot be written', () => {
|
||||
const command = 'a'.repeat(600)
|
||||
const staged = stageStartupCommand({
|
||||
@@ -216,12 +171,6 @@ describe('stageStartupCommand', () => {
|
||||
expect(staged.failure).toMatch(/ENOENT/)
|
||||
expect(staged.scriptPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('gives every launch its own script', () => {
|
||||
const first = stage('a'.repeat(600))
|
||||
const second = stage('a'.repeat(600))
|
||||
expect(first.scriptPath).not.toBe(second.scriptPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('discardStagedStartupCommand', () => {
|
||||
@@ -230,12 +179,6 @@ describe('discardStagedStartupCommand', () => {
|
||||
discardStagedStartupCommand(staged)
|
||||
expect(readdirSync(directory)).toEqual([])
|
||||
})
|
||||
|
||||
it('is a no-op once the script deleted itself', () => {
|
||||
const staged = stage('a'.repeat(600))
|
||||
rmSync(staged.scriptPath!)
|
||||
expect(() => discardStagedStartupCommand(staged)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sweepStaleStagedStartupCommands', () => {
|
||||
@@ -254,21 +197,3 @@ describe('sweepStaleStagedStartupCommands', () => {
|
||||
expect(readdirSync(directory).sort()).toEqual(['orca-launch-bbbb.sh', 'something-else.sh'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('startupStagingFailureNotice', () => {
|
||||
it('says nothing when staging did not fail', () => {
|
||||
expect(startupStagingFailureNotice(stage(`claude 'fix it'`))).toBeNull()
|
||||
expect(startupStagingFailureNotice(stage(`claude '${'x'.repeat(600)}'`))).toBeNull()
|
||||
})
|
||||
|
||||
it('prints one line naming the failure, with no control bytes from the reason', () => {
|
||||
const notice = startupStagingFailureNotice({
|
||||
command: 'x',
|
||||
delivery: 'typed-after-stage-failed',
|
||||
failure: 'EACCES: permission denied\nsecond line'
|
||||
})
|
||||
expect(notice).toBe(
|
||||
'\r\n[orca] Could not stage the launch command (EACCES: permission denied second line); typed it in full. If the shell shows quote> or waits for more input, press Ctrl-C and launch again.\r\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES
|
||||
} from './startup-line-prompt-carry'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import { RUNTIME_CAPABILITIES } from './protocol-version'
|
||||
import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-runtime-capability'
|
||||
|
||||
function offer(
|
||||
agent: TuiAgent,
|
||||
@@ -46,19 +44,6 @@ function promptForClaudeLineOf(bytes: number): string {
|
||||
}
|
||||
|
||||
describe('whether a launch prompt rides the typed startup line', () => {
|
||||
it('carries a short single-line prompt on the launch command', () => {
|
||||
const { plan, promptCarried } = offer('claude', 'explain this repo')
|
||||
expect(promptCarried).toBe(true)
|
||||
expect(plan?.launchCommand).toContain('explain this repo')
|
||||
})
|
||||
|
||||
it('carries a short Windows multi-line prompt as one encoded physical launch line', () => {
|
||||
const { plan, promptCarried } = offer('claude', linesOf(5, 40), { platform: 'win32' })
|
||||
expect(promptCarried).toBe(true)
|
||||
expect(plan?.launchCommand).not.toContain('\n')
|
||||
expect(plan?.launchCommand).toContain('`n')
|
||||
})
|
||||
|
||||
it('carries a line of exactly the budget and refuses one byte past it', () => {
|
||||
const atBudget = offer('claude', promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES))
|
||||
expect(new TextEncoder().encode(atBudget.plan?.launchCommand ?? '').byteLength).toBe(
|
||||
@@ -130,16 +115,6 @@ describe('whether a launch prompt rides the typed startup line', () => {
|
||||
expect(plan).not.toBeNull()
|
||||
expect(Object.values(plan?.env ?? {}).join('')).not.toContain('xxxx')
|
||||
})
|
||||
|
||||
it('never carries a stdin-after-start agent’s prompt, whose CLI takes none', () => {
|
||||
const { plan, promptCarried } = offer('aider', 'fix it')
|
||||
expect(promptCarried).toBe(false)
|
||||
expect(plan?.followupPrompt).toBeNull()
|
||||
})
|
||||
|
||||
it('reports nothing carried for an empty prompt', () => {
|
||||
expect(offer('claude', ' ').promptCarried).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Pinned live by startup-line-typed-length.live-shell.test.ts; the refusals below were measured on
|
||||
@@ -163,11 +138,6 @@ describe('a multi-line prompt typed into a shell the host names', () => {
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a long single line off a zsh launch command, which a late write truncates', () => {
|
||||
const prompt = promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)
|
||||
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
|
||||
})
|
||||
|
||||
it.each([['bash'], ['fish'], ['pwsh'], [undefined]])(
|
||||
'never types a multi-line line into %s',
|
||||
(shellName) => {
|
||||
@@ -212,10 +182,3 @@ describe('a short multi-line prompt on a Windows PowerShell host', () => {
|
||||
expect(plan?.launchCommand).toContain(prompt.replaceAll('\n', '`n'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('the capability clients gate a prompted launch on', () => {
|
||||
it('is advertised by every host that applies the typed-line rule', () => {
|
||||
// An older host folds any prompt into the typed line, so its absence is the gate.
|
||||
expect(RUNTIME_CAPABILITIES).toContain(AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTerminalGitHubPRLinkDetector } from './terminal-github-pr-link-detector'
|
||||
|
||||
const issue8126Url = 'https://github.com/owner/repo/pull/10'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
it('extracts GitHub pull request URLs from terminal output', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe('Created https://github.com/acme/orca/pull/42\r\n')).toEqual([
|
||||
{
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 42
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('skips nested schemes and continues through mixed HTTP and HTTPS candidates', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
const urls = [
|
||||
@@ -36,18 +20,6 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe(output).map((link) => link.url)).toEqual(urls)
|
||||
})
|
||||
|
||||
it('detects issue 8126 Claude Code PR links with attached ANSI reset', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe(`${issue8126Url}\x1b[22m\n`)).toEqual([
|
||||
{
|
||||
url: issue8126Url,
|
||||
slug: { owner: 'owner', repo: 'repo', host: 'github.com' },
|
||||
number: 10
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('strips an ANSI reset split across PTY chunks', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
@@ -61,20 +33,6 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects PR URLs corrupted by cursor movement', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe('https://github.com/owne\x1b[1Cr/repo/pull/10\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects PR URLs fused across terminal rows', () => {
|
||||
for (const cursorMove of ['\x1b[1A', '\x1b[1B']) {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe(`https://github.com/owner/repo/pull/${cursorMove}10\n`)).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fuse screen-editing controls into PR URLs', () => {
|
||||
for (const screenEdit of ['\x08', '\x0b', '\x0c', '\x1bD', '\x1b[2J', '\x1b[2K', '\x1b[1S']) {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
@@ -121,51 +79,6 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('trims terminal punctuation around printed URLs', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe('Opened (https://github.com/acme/orca/pull/42).\n')[0]?.url).toBe(
|
||||
'https://github.com/acme/orca/pull/42'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not repeat the same PR URL from overlapping carry text', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe('https://github.com/acme/orca/pull/42\n')).toHaveLength(1)
|
||||
expect(observe('more output\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores non-PR GitHub-shaped links', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe('https://github.com/acme/orca/issues/42\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('extracts GitHub Enterprise pull request URLs from terminal output', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe('Created https://github.my-company.net/MyOrg/my_repo/pull/395\r\n')).toEqual([
|
||||
{
|
||||
url: 'https://github.my-company.net/MyOrg/my_repo/pull/395',
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo', host: 'github.my-company.net' },
|
||||
number: 395
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts HTTP GitHub Enterprise pull request URLs from terminal output', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe('Created http://github.internal/MyOrg/my_repo/pull/395\r\n')).toEqual([
|
||||
{
|
||||
url: 'http://github.internal/MyOrg/my_repo/pull/395',
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo', host: 'github.internal' },
|
||||
number: 395
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts GitHub Enterprise pull request URLs with a custom port', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
@@ -179,28 +92,6 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('scans huge terminal chunks containing pull markers without global regex iteration', () => {
|
||||
const matchAll = vi.spyOn(String.prototype, 'matchAll')
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
const noise = `${'/pull/not-a-url '.repeat(20_000)}\n`
|
||||
|
||||
expect(observe(`${noise}Created https://github.com/acme/orca/pull/42\r\n`)).toEqual([
|
||||
{
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 42
|
||||
}
|
||||
])
|
||||
expect(matchAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops overlong incomplete URL carry instead of retaining pasted megabytes', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe(`https://github.com/acme/orca/pull/${'4'.repeat(10_000)}`)).toEqual([])
|
||||
expect(observe('2\r\n')).toEqual([])
|
||||
})
|
||||
|
||||
// Pin both sides of the 512-character carry cap and the overlong-URL drop path.
|
||||
it('still joins a PR URL split across chunks after a large scheme-free chunk', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
@@ -217,15 +108,6 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('drops carry when the scheme sits further back than the carry cap', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
// The URL opened >512 bytes before the chunk end, so it already overran the
|
||||
// cap and must not resurrect on the next chunk.
|
||||
expect(observe(`https://github.com/acme/orca/pull/${'4'.repeat(600)}`)).toEqual([])
|
||||
expect(observe('2\r\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps carry when the scheme-to-end tail is exactly at the cap', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
@@ -261,15 +143,6 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe('://github.com/acme/orca/pull/12\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not fabricate a link by splicing a stale fragment onto later output', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
// Keeping the 'h' would splice it onto the next chunk and emit a PR link for
|
||||
// a repo that never appeared in the stream.
|
||||
expect(observe(`https://github.com/acme/orca/pull/1${'x'.repeat(600)}h`)).toEqual([])
|
||||
expect(observe('ttps://github.com/zz/yy/pull/9\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('joins a plain http:// PR URL split across chunks', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
@@ -283,13 +156,6 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a trailing fragment when a plain http:// scheme sits behind the window', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
expect(observe(`http://github.internal/o/r/pull/1${'x'.repeat(600)}https`)).toEqual([])
|
||||
expect(observe('://github.com/a/b/pull/12\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a trailing scheme fragment when no scheme sits behind the window', () => {
|
||||
const observe = createTerminalGitHubPRLinkDetector()
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES,
|
||||
columnForTerminalFileLinkTap
|
||||
} from './terminal-file-link-conformance'
|
||||
import {
|
||||
extractTerminalFileLinkCandidates,
|
||||
extractTerminalFileLinks,
|
||||
findExactTerminalFileLink,
|
||||
isPathInsideWorktree,
|
||||
@@ -34,11 +32,6 @@ describe('terminal path helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps worktree-relative paths on Windows external files', () => {
|
||||
expect(isPathInsideWorktree('C:\\repo\\src\\file.ts', 'C:\\repo')).toBe(true)
|
||||
expect(toWorktreeRelativePath('C:\\repo\\src\\file.ts', 'C:\\repo')).toBe('src/file.ts')
|
||||
})
|
||||
|
||||
it('keeps worktree-relative paths for forward-slash Windows UNC paths', () => {
|
||||
expect(isPathInsideWorktree('//server/share/repo/src/file.ts', '\\\\server\\share\\repo')).toBe(
|
||||
true
|
||||
@@ -64,54 +57,10 @@ describe('terminal path helpers', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('emits tentative link candidates for each token in ls-style output', () => {
|
||||
const line = 'CLAUDE.md package.json pnpm-lock.yaml README.md'
|
||||
const links = extractTerminalFileLinks(line)
|
||||
const texts = links.map((link) => link.displayText)
|
||||
expect(texts).toEqual(['CLAUDE.md', 'package.json', 'pnpm-lock.yaml', 'README.md'])
|
||||
const claudeMd = links[0]
|
||||
expect(line.slice(claudeMd.startIndex, claudeMd.endIndex)).toBe('CLAUDE.md')
|
||||
})
|
||||
|
||||
it('recognises common extensionless project files (Makefile, LICENSE, …)', () => {
|
||||
const links = extractTerminalFileLinks('Makefile LICENSE README Dockerfile src tests')
|
||||
expect(links.map((link) => link.displayText).sort()).toEqual([
|
||||
'Dockerfile',
|
||||
'LICENSE',
|
||||
'Makefile',
|
||||
'README'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores pure numbers, flag-looking tokens, and dotfile-only strings', () => {
|
||||
expect(extractTerminalFileLinks('42 100 .. . -v --verbose src dist')).toEqual([])
|
||||
})
|
||||
|
||||
it('still strips trailing punctuation from bare filenames', () => {
|
||||
const links = extractTerminalFileLinks('See package.json, pnpm-lock.yaml.')
|
||||
expect(links.map((link) => link.displayText)).toEqual(['package.json', 'pnpm-lock.yaml'])
|
||||
})
|
||||
|
||||
it('does not double-link bare tokens that are part of an already-matched path', () => {
|
||||
const links = extractTerminalFileLinks('./src/file.ts is the entry point')
|
||||
expect(links.map((link) => link.displayText)).toEqual(['./src/file.ts'])
|
||||
})
|
||||
|
||||
it('carries line:column suffix on bare filenames (e.g. stack-trace output)', () => {
|
||||
const links = extractTerminalFileLinks('foo.ts:12:3 failed')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({ pathText: 'foo.ts', line: 12, column: 3 })
|
||||
})
|
||||
|
||||
it('keeps huge no-separator terminal tokens off the hover hot path', () => {
|
||||
const line = `${'b'.repeat(80 * 500)} package.json`
|
||||
const startedAt = performance.now()
|
||||
|
||||
const links = extractTerminalFileLinks(line)
|
||||
|
||||
expect(performance.now() - startedAt).toBeLessThan(100)
|
||||
expect(links.map((link) => link.displayText)).toEqual(['package.json'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTerminalFileLinks local path tokens', () => {
|
||||
@@ -124,35 +73,6 @@ describe('terminal path helpers', () => {
|
||||
).toEqual(['docs/café/report.pdf'])
|
||||
})
|
||||
|
||||
it('detects tilde-prefixed POSIX paths', () => {
|
||||
const links = extractTerminalFileLinks('~/Documents/Path/file_name')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({
|
||||
pathText: '~/Documents/Path/file_name',
|
||||
displayText: '~/Documents/Path/file_name'
|
||||
})
|
||||
})
|
||||
|
||||
it('detects absolute paths with spaces before the filename', () => {
|
||||
const links = extractTerminalFileLinks('/Users/Path/FolderName with Space/content.js')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({
|
||||
pathText: '/Users/Path/FolderName with Space/content.js',
|
||||
displayText: '/Users/Path/FolderName with Space/content.js'
|
||||
})
|
||||
})
|
||||
|
||||
it('stops a spaced absolute path before trailing prose', () => {
|
||||
const links = extractTerminalFileLinks(
|
||||
'Open /Users/Path/FolderName with Space/content.js for details'
|
||||
)
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({
|
||||
pathText: '/Users/Path/FolderName with Space/content.js',
|
||||
displayText: '/Users/Path/FolderName with Space/content.js'
|
||||
})
|
||||
})
|
||||
|
||||
it('detects an extensionless absolute path ending in a spaced segment', () => {
|
||||
const links = extractTerminalFileLinks('/Users/alice/My Folder')
|
||||
expect(links).toHaveLength(1)
|
||||
@@ -162,29 +82,11 @@ describe('terminal path helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('trims terminal padding after line-ending spaced paths', () => {
|
||||
const links = extractTerminalFileLinks('/Users/alice/My Folder ')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({
|
||||
pathText: '/Users/alice/My Folder',
|
||||
displayText: '/Users/alice/My Folder'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat mid-line command arguments as line-ending spaced paths', () => {
|
||||
const links = extractTerminalFileLinks('run /usr/bin/env node, then continue')
|
||||
expect(links.map((link) => link.pathText)).not.toContain('/usr/bin/env node')
|
||||
})
|
||||
|
||||
it('keeps trailing separators on directory-like absolute paths', () => {
|
||||
const links = extractTerminalFileLinks('/Users/alice/worktree/')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({
|
||||
pathText: '/Users/alice/worktree/',
|
||||
displayText: '/Users/alice/worktree/'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not linkify root-only or relative trailing separator tokens', () => {
|
||||
expect(extractTerminalFileLinks('progress 1 / 3')).toEqual([])
|
||||
expect(extractTerminalFileLinks('/')).toEqual([])
|
||||
@@ -194,15 +96,6 @@ describe('terminal path helpers', () => {
|
||||
expect(extractTerminalFileLinks('C:\\')).toEqual([])
|
||||
})
|
||||
|
||||
it('detects an extensionless relative path ending in a spaced segment', () => {
|
||||
const links = extractTerminalFileLinks('./My Folder')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({
|
||||
pathText: './My Folder',
|
||||
displayText: './My Folder'
|
||||
})
|
||||
})
|
||||
|
||||
it('detects framework route paths with bracket and paren segments', () => {
|
||||
const links = extractTerminalFileLinks(
|
||||
'Error in app/(shop)/products/[productId]/page.tsx:42:7'
|
||||
@@ -215,84 +108,6 @@ describe('terminal path helpers', () => {
|
||||
displayText: 'app/(shop)/products/[productId]/page.tsx:42:7'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles large spaced path lists without quadratic overlap scans', () => {
|
||||
const line = Array.from({ length: 20_000 }, () => '/tmp/Foo Bar/file').join(', ')
|
||||
|
||||
const links = extractTerminalFileLinks(line)
|
||||
|
||||
expect(links).toHaveLength(20_000)
|
||||
expect(links[0].pathText).toBe('/tmp/Foo Bar/file')
|
||||
}, 5_000)
|
||||
|
||||
it('keeps extension-heavy assistant prose on a bounded scan path', () => {
|
||||
const filenames = Array.from({ length: 20_000 }, (_, index) => `report-${index}.txt`)
|
||||
const links = extractTerminalFileLinks(`/tmp/root/${filenames.join(' ')}`)
|
||||
|
||||
expect(links).toHaveLength(2)
|
||||
expect(links[0].pathText).toBe(`/tmp/root/${filenames.slice(0, -1).join(' ')}`)
|
||||
expect(links.at(-1)?.pathText).toBe('report-19999.txt')
|
||||
})
|
||||
})
|
||||
|
||||
it('supports Windows cwd resolution for terminal file links', () => {
|
||||
expect(
|
||||
resolveTerminalFileLink(
|
||||
{
|
||||
pathText: '.\\src\\file.ts',
|
||||
line: 12,
|
||||
column: 3,
|
||||
startIndex: 0,
|
||||
endIndex: 13,
|
||||
displayText: '.\\src\\file.ts:12:3'
|
||||
},
|
||||
'C:\\repo'
|
||||
)
|
||||
).toEqual({
|
||||
absolutePath: 'C:/repo/src/file.ts',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves tilde-prefixed POSIX paths against the cwd user home', () => {
|
||||
expect(
|
||||
resolveTerminalFileLink(
|
||||
{
|
||||
pathText: '~/Documents/Path/file_name',
|
||||
line: null,
|
||||
column: null,
|
||||
startIndex: 0,
|
||||
endIndex: 26,
|
||||
displayText: '~/Documents/Path/file_name'
|
||||
},
|
||||
'/Users/alice/project'
|
||||
)
|
||||
).toEqual({
|
||||
absolutePath: '/Users/alice/Documents/Path/file_name',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves tilde-prefixed Windows paths against the cwd user home', () => {
|
||||
expect(
|
||||
resolveTerminalFileLink(
|
||||
{
|
||||
pathText: '~/Documents/Path/file_name',
|
||||
line: null,
|
||||
column: null,
|
||||
startIndex: 0,
|
||||
endIndex: 26,
|
||||
displayText: '~/Documents/Path/file_name'
|
||||
},
|
||||
'C:\\Users\\Alice\\project'
|
||||
)
|
||||
).toEqual({
|
||||
absolutePath: 'C:/Users/Alice/Documents/Path/file_name',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves tilde-prefixed paths against explicit terminal home when cwd is outside home', () => {
|
||||
@@ -316,14 +131,6 @@ describe('terminal path helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('finds exact repo-relative OSC hyperlink text', () => {
|
||||
expect(findExactTerminalFileLink('docs/README.md')).toMatchObject({
|
||||
pathText: 'docs/README.md',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps line and column suffixes from exact OSC hyperlink text', () => {
|
||||
expect(findExactTerminalFileLink('docs/README.md:12:3')).toMatchObject({
|
||||
pathText: 'docs/README.md',
|
||||
@@ -354,12 +161,5 @@ describe('terminal path helpers', () => {
|
||||
const links = extractTerminalFileLinks('file:///Users/dev/orca/report.html')
|
||||
expect(links.map((link) => link.displayText)).toEqual(['file:///Users/dev/orca/report.html'])
|
||||
})
|
||||
|
||||
it('exposes file:// URIs to the hover candidate pass as well', () => {
|
||||
const candidates = extractTerminalFileLinkCandidates('file:///tmp/out.txt:9')
|
||||
expect(candidates.some((link) => link.pathText === '/tmp/out.txt' && link.line === 9)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
// fish toggles 2031 around each prompt, often in one chunk. Acting on any subscribe instead of final state sends replies into the shell/child (#9993).
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
scanMode2031ReplyDecision,
|
||||
scanMode2031Sequences,
|
||||
type Mode2031ReplyDecision
|
||||
} from './terminal-color-scheme-protocol'
|
||||
|
||||
import {
|
||||
createTerminalTitleTracker,
|
||||
type TerminalTitleTrackerCallbacks
|
||||
@@ -14,9 +9,6 @@ import {
|
||||
const ESC = '\x1b'
|
||||
const BEL = '\x07'
|
||||
|
||||
// A fish prompt cycle: subscribe, paint the prompt, hand the tty to the child.
|
||||
const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m❯${ESC}[0m ${ESC}[?2031l`
|
||||
|
||||
function trackerRecording(overrides: TerminalTitleTrackerCallbacks = {}): {
|
||||
subscribes: number
|
||||
tracker: ReturnType<typeof createTerminalTitleTracker>
|
||||
@@ -37,52 +29,6 @@ function trackerRecording(overrides: TerminalTitleTrackerCallbacks = {}): {
|
||||
}
|
||||
|
||||
describe('DECSET 2031 replies follow the chunk-final state (#9993)', () => {
|
||||
it('reports a subscribe-then-unsubscribe chunk as unsubscribed', () => {
|
||||
const scan = scanMode2031Sequences('', FISH_PROMPT_HANDOFF)
|
||||
|
||||
// Both toggles occurred, but only finalState says whether the shell is still listening.
|
||||
expect(scan.subscribe).toBe(true)
|
||||
expect(scan.unsubscribe).toBe(true)
|
||||
expect(scan.finalState).toBe('unsubscribed')
|
||||
})
|
||||
|
||||
it('does not emit a 2031-subscribe fact when the shell unsubscribed in the same chunk', () => {
|
||||
const recorded = trackerRecording()
|
||||
|
||||
recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF)
|
||||
|
||||
expect(recorded.subscribes).toBe(0)
|
||||
})
|
||||
|
||||
it('still emits a fact when the chunk ends subscribed', () => {
|
||||
const recorded = trackerRecording()
|
||||
|
||||
recorded.tracker.handleChunk(`${ESC}[?2031l${ESC}[?2031h`)
|
||||
|
||||
expect(recorded.subscribes).toBe(1)
|
||||
})
|
||||
|
||||
it('emits once per chunk that ends subscribed, across a fish prompt loop', () => {
|
||||
const recorded = trackerRecording()
|
||||
|
||||
// Three prompt cycles, then a TUI that subscribes and keeps listening.
|
||||
recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF)
|
||||
recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF)
|
||||
recorded.tracker.handleChunk(FISH_PROMPT_HANDOFF)
|
||||
recorded.tracker.handleChunk(`${ESC}[?2031h`)
|
||||
|
||||
expect(recorded.subscribes).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps answering a subscribe split across chunk boundaries', () => {
|
||||
const recorded = trackerRecording()
|
||||
|
||||
recorded.tracker.handleChunk(`${ESC}[?20`)
|
||||
recorded.tracker.handleChunk('31h')
|
||||
|
||||
expect(recorded.subscribes).toBe(1)
|
||||
})
|
||||
|
||||
it('does not answer a subscribe whose withdrawal straddles the chunk boundary', () => {
|
||||
const recorded = trackerRecording()
|
||||
|
||||
@@ -162,19 +108,6 @@ function trackerRecordingBothFacts(): {
|
||||
}
|
||||
|
||||
describe('a fish prompt-accept burst is tracked, never answered (#9993)', () => {
|
||||
it('yields registry transitions only — subscribe then unsubscribe, two chunks apart', () => {
|
||||
let state = INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
const decisions: Mode2031ReplyDecision[] = FISH_PROMPT_ACCEPT_CHUNKS.map((chunk) => {
|
||||
const result = scanMode2031ReplyDecision(state, chunk)
|
||||
state = result.state
|
||||
return result.decision
|
||||
})
|
||||
|
||||
expect(decisions).toEqual(['subscribed', null, null, 'unsubscribed', null])
|
||||
// The shell withdraws before exec; a reply queued at subscribe can reach only its child.
|
||||
expect(decisions.indexOf('unsubscribed')).toBeLessThan(FISH_PROMPT_ACCEPT_CHUNKS.length - 1)
|
||||
})
|
||||
|
||||
it('still emits both side-effect facts so the subscription registry stays truthful', () => {
|
||||
const recorded = trackerRecordingBothFacts()
|
||||
|
||||
@@ -185,13 +118,4 @@ describe('a fish prompt-accept burst is tracked, never answered (#9993)', () =>
|
||||
// Facts survive the fix: maybePushMode2031Flip needs them to know who is listening.
|
||||
expect(recorded.facts).toEqual(['2031-subscribe', '2031-unsubscribe'])
|
||||
})
|
||||
|
||||
it('contains no color-scheme query, so silence is the whole correct answer', () => {
|
||||
const burst = FISH_PROMPT_ACCEPT_CHUNKS.join('')
|
||||
|
||||
// A captured full fish session subscribes but never queries CSI ?996n, so ?997;Nn would be unsolicited.
|
||||
expect(burst).toContain(`${ESC}[?2031h`)
|
||||
expect(burst).not.toContain('996')
|
||||
expect(burst).not.toContain('997')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,23 +35,6 @@ function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}):
|
||||
}
|
||||
|
||||
describe('createTerminalTitleTracker command-finished facts', () => {
|
||||
it('emits command-finished with best-effort exit codes', () => {
|
||||
const { events, tracker } = createRecordingTracker()
|
||||
|
||||
tracker.handleChunk(`before${ESC}]133;A${BEL}prompt${ESC}]133;B${BEL}`)
|
||||
tracker.handleChunk(`${ESC}]133;C${BEL}running${ESC}]133;D;0${BEL}`)
|
||||
tracker.handleChunk(`${ESC}]133;D;130${BEL}`)
|
||||
tracker.handleChunk(`${ESC}]133;D;not-a-number${BEL}`)
|
||||
tracker.handleChunk(`${ESC}]133;D${BEL}`)
|
||||
|
||||
expect(events).toEqual([
|
||||
['finished', 0],
|
||||
['finished', 130],
|
||||
['finished', null],
|
||||
['finished', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('detects OSC 133;D split across chunk boundaries (BEL and ST terminated)', () => {
|
||||
const { events, tracker } = createRecordingTracker()
|
||||
|
||||
@@ -90,19 +73,6 @@ describe('createTerminalTitleTracker command-finished facts', () => {
|
||||
})
|
||||
|
||||
describe('createTerminalTitleTracker pr-link facts', () => {
|
||||
it('emits one fact per PR URL including multiple links in one chunk', () => {
|
||||
const { events, tracker } = createRecordingTracker()
|
||||
|
||||
tracker.handleChunk(
|
||||
'see https://github.com/acme/orca/pull/42 and https://github.com/acme/orca/pull/43 \r\n'
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['pr', 'https://github.com/acme/orca/pull/42', 42],
|
||||
['pr', 'https://github.com/acme/orca/pull/43', 43]
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for a boundary when a URL splits across chunks and dedupes repeats', () => {
|
||||
const { events, tracker } = createRecordingTracker()
|
||||
|
||||
@@ -113,18 +83,6 @@ describe('createTerminalTitleTracker pr-link facts', () => {
|
||||
|
||||
expect(events).toEqual([['pr', 'https://github.com/acme/orca/pull/42', 42]])
|
||||
})
|
||||
|
||||
it('skips the 133/URL scans entirely when no consumer is registered', () => {
|
||||
// Headless serve has no side-effect consumer; it must not allocate scanners or pay their per-chunk cost.
|
||||
const titles: string[] = []
|
||||
const tracker = createTerminalTitleTracker({
|
||||
onTitle: (normalized) => titles.push(normalized)
|
||||
})
|
||||
|
||||
tracker.handleChunk(`${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n`)
|
||||
|
||||
expect(titles).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTerminalTitleTracker 2031-subscribe facts', () => {
|
||||
@@ -136,33 +94,6 @@ describe('createTerminalTitleTracker 2031-subscribe facts', () => {
|
||||
expect(events).toEqual([['2031-subscribe'], ['bell']])
|
||||
})
|
||||
|
||||
it('detects a subscribe split across chunk boundaries', () => {
|
||||
const { events, tracker } = createRecordingTracker()
|
||||
|
||||
tracker.handleChunk(`${ESC}[?20`)
|
||||
tracker.handleChunk('31h')
|
||||
|
||||
expect(events).toEqual([['2031-subscribe']])
|
||||
})
|
||||
|
||||
it('emits an unsubscribe fact so gated views can retire the subscription', () => {
|
||||
// Gated views miss withdrawal bytes; this fact prevents unsolicited theme replies after unsubscribe (#9993).
|
||||
const { events, tracker } = createRecordingTracker()
|
||||
|
||||
tracker.handleChunk(`${ESC}[?2031l`)
|
||||
|
||||
expect(events).toEqual([['2031-unsubscribe']])
|
||||
})
|
||||
|
||||
it('emits nothing for chunks that carry no 2031 bytes at all', () => {
|
||||
// finalState is null here, not 'unsubscribed' — ordinary output must stay silent.
|
||||
const { events, tracker } = createRecordingTracker()
|
||||
|
||||
tracker.handleChunk('plain build output\r\n')
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('reports only the chunk-final state when a chunk toggles 2031 both ways', () => {
|
||||
// fish enables and disables 2031 around every prompt; one chunk, one decision.
|
||||
const { events, tracker } = createRecordingTracker()
|
||||
@@ -172,17 +103,6 @@ describe('createTerminalTitleTracker 2031-subscribe facts', () => {
|
||||
|
||||
expect(events).toEqual([['2031-unsubscribe'], ['2031-subscribe']])
|
||||
})
|
||||
|
||||
it('skips the 2031 scan entirely when no consumer is registered', () => {
|
||||
const { events, tracker } = createRecordingTracker({
|
||||
onMode2031Subscribe: undefined,
|
||||
onMode2031Unsubscribe: undefined
|
||||
})
|
||||
|
||||
tracker.handleChunk(`${ESC}[?2031h`)
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTerminalTitleTracker synthetic-frame isolation', () => {
|
||||
|
||||
@@ -24,55 +24,9 @@ describe('advanceTerminalEscapeBoundary', () => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('matches the full-tail scanner at every split, without retaining payloads', () => {
|
||||
const streams = [
|
||||
'\x1b[38;2;115;118;123;48;2;65;69;76m⠁',
|
||||
'\x1b\x00\t[31;1mred\x1b[0m',
|
||||
'\x1b]0;title\x1b\\plain',
|
||||
'\x1b\x00]0;title\x07',
|
||||
'\x1bP$qm\x1b\\',
|
||||
'\x1b_Gpayload\x1b\\',
|
||||
'\x1b q\x1b[31;\x18plain',
|
||||
'\x1b]0;title\x1b\x1b[31mred',
|
||||
'\x1bPpayload\x1b[32mgreen',
|
||||
'\x1b[31;\x1a',
|
||||
`\x1b]0;${'x'.repeat(MAX_PARTIAL_ESCAPE_TAIL_LENGTH + 1)}\x1b\\`
|
||||
]
|
||||
for (const stream of streams) {
|
||||
let state = ''
|
||||
for (let i = 0; i < stream.length; i += 1) {
|
||||
state = advanceTerminalEscapeBoundary(state, stream[i])
|
||||
expect(state.length).toBeLessThanOrEqual(3)
|
||||
expect(state === '', JSON.stringify({ stream: stream.slice(0, 50), i })).toBe(
|
||||
extractPartialEscapeTail(stream.slice(0, i + 1)) === ''
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractPartialEscapeTail', () => {
|
||||
it('returns empty for parser-clean streams', () => {
|
||||
expect(extractPartialEscapeTail('')).toBe('')
|
||||
expect(extractPartialEscapeTail('plain text no escapes')).toBe('')
|
||||
expect(extractPartialEscapeTail('\x1b[38;5;196mred\x1b[0m done')).toBe('')
|
||||
expect(extractPartialEscapeTail('\x1b[2J\x1b[H')).toBe('')
|
||||
})
|
||||
|
||||
it('returns the dangling CSI when a chunk ends mid-sequence', () => {
|
||||
expect(extractPartialEscapeTail('hello\x1b[3')).toBe('\x1b[3')
|
||||
expect(extractPartialEscapeTail('a\x1b[38;5;')).toBe('\x1b[38;5;')
|
||||
expect(extractPartialEscapeTail('\x1b')).toBe('\x1b')
|
||||
expect(extractPartialEscapeTail('\x1b[')).toBe('\x1b[')
|
||||
})
|
||||
|
||||
it('returns the dangling OSC (unterminated) sequence', () => {
|
||||
expect(extractPartialEscapeTail('\x1b]0;my-title')).toBe('\x1b]0;my-title')
|
||||
expect(extractPartialEscapeTail('\x1b]0;title\x07after')).toBe('')
|
||||
expect(extractPartialEscapeTail('\x1b]0;title\x1b\\after')).toBe('')
|
||||
})
|
||||
|
||||
it('treats a fresh ESC as aborting a pending CSI', () => {
|
||||
expect(extractPartialEscapeTail('\x1b[3\x1b[0m')).toBe('')
|
||||
expect(extractPartialEscapeTail('\x1b[3\x1b[')).toBe('\x1b[')
|
||||
@@ -106,40 +60,9 @@ describe('extractPartialEscapeTail', () => {
|
||||
expect(extractPartialEscapeTail('\x1bPq\x1b\x1b[3')).toBe('\x1b[3')
|
||||
expect(extractPartialEscapeTail('\x1b]0;t\x1b\x1b')).toBe('\x1b')
|
||||
})
|
||||
|
||||
it('is fold-safe across chunk boundaries', () => {
|
||||
// extract(a + b) === extract(extract(a) + b) — the invariant ingest relies on.
|
||||
const cases: [string, string][] = [
|
||||
['first\x1b[3', '8;5;196mred'],
|
||||
['\x1b', '[0m'],
|
||||
['\x1b]0;ti', 'tle\x07'],
|
||||
['clean', '\x1b[1'],
|
||||
// Fold-safety must hold across the CAN abort too.
|
||||
['\x1b', '\x18after'],
|
||||
['\x1b ', '\x18after'],
|
||||
// Boundary landing inside stringEsc/oscEsc — the two states that used to break the fold.
|
||||
['\x1bPx\x1b\x18', 'X0abc'],
|
||||
['\x1b]0;t\x1b\x1a', 'X0abc'],
|
||||
['\x1b] \x1b\x1b', '^'],
|
||||
['\x1bPq\x1b\x1b', '[3']
|
||||
]
|
||||
for (const [a, b] of cases) {
|
||||
expect(extractPartialEscapeTail(extractPartialEscapeTail(a) + b)).toBe(
|
||||
extractPartialEscapeTail(a + b)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('advancePartialEscapeTail', () => {
|
||||
it('accumulates a split sequence across chunks', () => {
|
||||
let tail = ''
|
||||
tail = advancePartialEscapeTail(tail, 'ls\r\n\x1b[3')
|
||||
expect(tail).toBe('\x1b[3')
|
||||
tail = advancePartialEscapeTail(tail, '8;5;196m')
|
||||
expect(tail).toBe('') // sequence completed
|
||||
})
|
||||
|
||||
it('abandons tracking (returns empty) past the cap', () => {
|
||||
// An unterminated OSC longer than the cap degrades to pre-fix behavior.
|
||||
const huge = `\x1b]0;${'x'.repeat(MAX_PARTIAL_ESCAPE_TAIL_LENGTH + 10)}`
|
||||
@@ -148,35 +71,6 @@ describe('advancePartialEscapeTail', () => {
|
||||
})
|
||||
|
||||
describe('advancePartialEscapeTail ESC-free fast path', () => {
|
||||
// Compare every pending-state/chunk pairing against the unconditional fold.
|
||||
const pieces = [
|
||||
'',
|
||||
'plain output\n',
|
||||
'\x1b[32mgreen\x1b[0m',
|
||||
'\x1b[3',
|
||||
'\x1b]0;title\x07',
|
||||
'\x1b]0;partial',
|
||||
'\x1bP dcs payload',
|
||||
'\x1b',
|
||||
'\x18',
|
||||
'\x1a',
|
||||
'\x1b]8;;https://example.com\x1b\\',
|
||||
'\x1b(',
|
||||
'\x1b[1;2;3'
|
||||
]
|
||||
|
||||
it('matches an unconditional fold for every pending-tail and chunk pairing', () => {
|
||||
for (const pending of pieces.map((piece) => extractPartialEscapeTail(piece))) {
|
||||
for (const chunk of pieces) {
|
||||
// Apply the cap to the oracle too or large pairings compare different contracts.
|
||||
const unguarded = extractPartialEscapeTail(pending + chunk)
|
||||
expect(advancePartialEscapeTail(pending, chunk), JSON.stringify({ pending, chunk })).toBe(
|
||||
unguarded.length > MAX_PARTIAL_ESCAPE_TAIL_LENGTH ? '' : unguarded
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('still carries a pending tail through an ESC-free chunk', () => {
|
||||
expect(advancePartialEscapeTail('\x1b]0;my-title', ' still in the OSC')).toBe(
|
||||
'\x1b]0;my-title still in the OSC'
|
||||
|
||||
@@ -29,42 +29,6 @@ describe('isTerminalQueryReply', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('matches synthetic query replies that must be sent immediately', () => {
|
||||
// CPR cursor position report (answer to CSI 6n) — the #7329 culprit.
|
||||
expect(isTerminalQueryReply('\x1b[3;1R')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1b[22;1R')).toBe(true)
|
||||
// DSR device status.
|
||||
expect(isTerminalQueryReply('\x1b[0n')).toBe(true)
|
||||
// Contour color-scheme report (answer to CSI ?996n / mode-2031 push).
|
||||
expect(isTerminalQueryReply('\x1b[?997;1n')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1b[?997;2n')).toBe(true)
|
||||
// DA1/DA2/DA3 device attributes.
|
||||
expect(isTerminalQueryReply('\x1b[?1;2c')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1b[?61;4c')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1b[>0;276;0c')).toBe(true)
|
||||
// Window/cell pixel-size reports.
|
||||
expect(isTerminalQueryReply('\x1b[6;16;8t')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1b[4;384;640t')).toBe(true)
|
||||
// DECRPM mode report — private (with ?) and ANSI (without ?).
|
||||
expect(isTerminalQueryReply('\x1b[?2026;2$y')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1b[4;1$y')).toBe(true)
|
||||
// OSC 10/11 color responses (the #7329 culprit) — BEL and ST terminated.
|
||||
expect(isTerminalQueryReply('\x1b]11;rgb:2828/2c2c/3434\x1b\\')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1b]10;rgb:c0c0/c0c0/c0c0\x07')).toBe(true)
|
||||
// DECXCPR extended cursor position report (answer to CSI ? 6n).
|
||||
expect(isTerminalQueryReply('\x1b[?12;5R')).toBe(true)
|
||||
// Text-area size in characters (answer to CSI 18t).
|
||||
expect(isTerminalQueryReply('\x1b[8;24;80t')).toBe(true)
|
||||
// Crossterm probes Kitty flags at startup; delaying replies corrupts input like CPR did.
|
||||
expect(isTerminalQueryReply('\x1b[?0u')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1b[?31u')).toBe(true)
|
||||
// DCS DECRQSS reports (vim queries cursor style via DCS $ q) + XTVERSION.
|
||||
expect(isTerminalQueryReply('\x1bP1$r2 q\x1b\\')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1bP1$r0m\x1b\\')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1bP0$r\x1b\\')).toBe(true)
|
||||
expect(isTerminalQueryReply('\x1bP>|xterm.js(5.6.0)\x1b\\')).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies the fully framed XTVERSION reply emitted by real xterm', async () => {
|
||||
const terminal = new Terminal()
|
||||
const replies: string[] = []
|
||||
@@ -82,11 +46,6 @@ describe('isTerminalQueryReply', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('documents the accepted modified-F3/CPR collision', () => {
|
||||
// Modified F3 is byte-identical to CPR; accept the ambiguity documented in terminal-query-reply.ts.
|
||||
expect(isTerminalQueryReply('\x1b[1;2R')).toBe(true)
|
||||
})
|
||||
|
||||
it('routes only cooked-echo-risk replies through the ECHO-safe write path', () => {
|
||||
// Color-scheme private DSR + OSC color — cooked prompt paint risk (#13137).
|
||||
expect(needsCookedEchoSafeQueryReply('\x1b[?997;1n')).toBe(true)
|
||||
@@ -206,54 +165,4 @@ describe('query reply ordering (termenv OSC-then-CPR)', () => {
|
||||
ingress.drainAndClose()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps a CPR immediate when no color reply is deferred', () => {
|
||||
vi.useFakeTimers()
|
||||
const pty: string[] = []
|
||||
const ingress = new PtyStartupIngress({
|
||||
ownerBackend: 'posix-pty',
|
||||
write: (data) => pty.push(data),
|
||||
onEmission: () => {}
|
||||
})
|
||||
|
||||
hostWrites(ingress, pty)(CPR_REPLY)
|
||||
expect(pty).toEqual([CPR_REPLY])
|
||||
ingress.drainAndClose()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('never takes ordinary typed input', () => {
|
||||
const pty: string[] = []
|
||||
const ingress = new PtyStartupIngress({
|
||||
ownerBackend: 'posix-pty',
|
||||
write: (data) => pty.push(data),
|
||||
onEmission: () => {}
|
||||
})
|
||||
|
||||
for (const keystroke of ['y', 'gh auth login\r', '\x1b[A', '\x1b', '\x03']) {
|
||||
expect(ingress.answerLiveQueryReply(keystroke)).toBe(false)
|
||||
}
|
||||
ingress.drainAndClose()
|
||||
})
|
||||
})
|
||||
|
||||
// Known limit: FIFO covers recognized replies, not all PTY bytes. Whole-string matching excludes mixed payloads and ordinary input; routing them through FIFO later is an improvement.
|
||||
describe('writes that still bypass the ordered queue', () => {
|
||||
const BYPASSING = [
|
||||
{ what: 'reply coalesced with a keystroke', data: '\x1b[6;1Ry' },
|
||||
{ what: 'keystroke coalesced with a reply', data: 'y\x1b[6;1R' },
|
||||
{ what: 'ordinary typed input', data: 'ls\r' }
|
||||
]
|
||||
|
||||
it.each(BYPASSING)('does not take $what', ({ data }) => {
|
||||
const ingress = new PtyStartupIngress({
|
||||
ownerBackend: 'posix-pty',
|
||||
write: () => {},
|
||||
onEmission: () => {}
|
||||
})
|
||||
// Deferral is open, so an ordered write WOULD be queued here.
|
||||
expect(ingress.answerLiveQueryReply('\x1b]11;rgb:00/00/00\x07')).toBe(true)
|
||||
expect(ingress.answerLiveQueryReply(data)).toBe(false)
|
||||
ingress.drainAndClose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyTerminalQuickCommandMutation,
|
||||
buildTerminalQuickCommandInput,
|
||||
flattenTerminalQuickCommand,
|
||||
getTerminalQuickCommandAction,
|
||||
getTerminalQuickCommandBody,
|
||||
getDefaultTerminalQuickCommands,
|
||||
isTerminalQuickCommandComplete,
|
||||
normalizeTerminalQuickCommands,
|
||||
parseNormalizedTerminalQuickCommands,
|
||||
supportsTerminalAgentQuickCommand,
|
||||
terminalQuickCommandMatchesRepo
|
||||
} from './terminal-quick-commands'
|
||||
|
||||
describe('terminal quick commands', () => {
|
||||
it('returns safe defaults when persisted settings are missing', () => {
|
||||
expect(normalizeTerminalQuickCommands(undefined)).toEqual([])
|
||||
expect(getDefaultTerminalQuickCommands()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an intentionally empty command list', () => {
|
||||
expect(normalizeTerminalQuickCommands([])).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves lists larger than forty commands across loading and protocol parsing', () => {
|
||||
const commands = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `command-${index}`,
|
||||
@@ -37,106 +22,6 @@ describe('terminal quick commands', () => {
|
||||
expect(parseNormalizedTerminalQuickCommands(commands)).toEqual(commands)
|
||||
})
|
||||
|
||||
it('removes quick commands from the abandoned preset rollout', () => {
|
||||
expect(
|
||||
normalizeTerminalQuickCommands([
|
||||
{
|
||||
id: 'default-pwd',
|
||||
label: 'Print Working Directory',
|
||||
command: 'pwd',
|
||||
appendEnter: true
|
||||
},
|
||||
{
|
||||
id: 'default-git-status',
|
||||
label: 'Git Status',
|
||||
command: 'git status',
|
||||
appendEnter: true
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('drops malformed entries and normalizes valid commands and drafts', () => {
|
||||
expect(
|
||||
normalizeTerminalQuickCommands([
|
||||
null,
|
||||
{ id: 'status', label: ' Status ', command: 'git status\n', appendEnter: false },
|
||||
{ id: 'empty-command', label: 'Empty', command: ' ' },
|
||||
{ id: 'status', label: 'Duplicate', command: 'pwd' },
|
||||
{ label: 'No ID', command: 'date' }
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
action: 'terminal-command',
|
||||
command: 'git status',
|
||||
appendEnter: false,
|
||||
scope: { type: 'global' }
|
||||
},
|
||||
{
|
||||
id: 'empty-command',
|
||||
label: 'Empty',
|
||||
action: 'terminal-command',
|
||||
command: '',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
},
|
||||
{
|
||||
id: 'status-2',
|
||||
label: 'Duplicate',
|
||||
action: 'terminal-command',
|
||||
command: 'pwd',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
},
|
||||
{
|
||||
id: 'quick-command-4',
|
||||
label: 'No ID',
|
||||
action: 'terminal-command',
|
||||
command: 'date',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes repository scoped commands and falls back to global for invalid scopes', () => {
|
||||
expect(
|
||||
normalizeTerminalQuickCommands([
|
||||
{
|
||||
id: 'repo-dev',
|
||||
label: 'Dev',
|
||||
command: 'pnpm dev',
|
||||
scope: { type: 'repo', repoId: ' repo-1 ' }
|
||||
},
|
||||
{
|
||||
id: 'bad-repo',
|
||||
label: 'Bad',
|
||||
command: 'echo bad',
|
||||
scope: { type: 'repo', repoId: ' ' }
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
id: 'repo-dev',
|
||||
label: 'Dev',
|
||||
action: 'terminal-command',
|
||||
command: 'pnpm dev',
|
||||
appendEnter: true,
|
||||
scope: { type: 'repo', repoId: 'repo-1' }
|
||||
},
|
||||
{
|
||||
id: 'bad-repo',
|
||||
label: 'Bad',
|
||||
action: 'terminal-command',
|
||||
command: 'echo bad',
|
||||
appendEnter: true,
|
||||
scope: { type: 'global' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes agent prompt commands without storing generated shell text', () => {
|
||||
expect(
|
||||
normalizeTerminalQuickCommands([
|
||||
@@ -298,88 +183,9 @@ describe('terminal quick commands', () => {
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('formats terminal input without assuming shell semantics', () => {
|
||||
expect(
|
||||
buildTerminalQuickCommandInput({
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
command: 'git status',
|
||||
appendEnter: true
|
||||
})
|
||||
).toBe('git status\r')
|
||||
expect(
|
||||
buildTerminalQuickCommandInput({
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
command: 'git status',
|
||||
appendEnter: false
|
||||
})
|
||||
).toBe('git status')
|
||||
})
|
||||
|
||||
it('classifies quick command actions and body text', () => {
|
||||
const terminal = {
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
command: 'git status',
|
||||
appendEnter: true
|
||||
}
|
||||
const agent = {
|
||||
id: 'agent',
|
||||
label: 'Agent',
|
||||
action: 'agent-prompt' as const,
|
||||
agent: 'claude' as const,
|
||||
prompt: 'Fix the tests'
|
||||
}
|
||||
|
||||
expect(getTerminalQuickCommandAction(terminal)).toBe('terminal-command')
|
||||
expect(getTerminalQuickCommandBody(terminal)).toBe('git status')
|
||||
expect(isTerminalQuickCommandComplete(terminal)).toBe(true)
|
||||
expect(getTerminalQuickCommandAction(agent)).toBe('agent-prompt')
|
||||
expect(getTerminalQuickCommandBody(agent)).toBe('Fix the tests')
|
||||
expect(isTerminalQuickCommandComplete(agent)).toBe(true)
|
||||
})
|
||||
|
||||
it('only allows agent prompt quick commands for launch-time prompt agents', () => {
|
||||
expect(supportsTerminalAgentQuickCommand('claude')).toBe(true)
|
||||
expect(supportsTerminalAgentQuickCommand('gemini')).toBe(true)
|
||||
expect(supportsTerminalAgentQuickCommand('aider')).toBe(false)
|
||||
expect(supportsTerminalAgentQuickCommand('not-real')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenTerminalQuickCommand', () => {
|
||||
it('returns the same object when there are no line breaks', () => {
|
||||
const command = {
|
||||
id: 'test',
|
||||
label: 'Test',
|
||||
command: 'git status',
|
||||
appendEnter: true
|
||||
} as const
|
||||
expect(flattenTerminalQuickCommand(command)).toBe(command)
|
||||
})
|
||||
|
||||
it('replaces newlines with semicolons and spaces', () => {
|
||||
const result = flattenTerminalQuickCommand({
|
||||
id: 'test',
|
||||
label: 'Test',
|
||||
command: 'cd packages\nbun run build\ncd ..',
|
||||
appendEnter: true
|
||||
})
|
||||
expect(result.command).toBe('cd packages; bun run build; cd ..')
|
||||
})
|
||||
|
||||
it('collapses consecutive newlines into a single separator', () => {
|
||||
const result = flattenTerminalQuickCommand({
|
||||
id: 'test',
|
||||
label: 'Test',
|
||||
command: 'echo one\n\n\necho two',
|
||||
appendEnter: true
|
||||
})
|
||||
expect(result.command).toBe('echo one; echo two')
|
||||
})
|
||||
|
||||
it('handles Windows-style CRLF endings', () => {
|
||||
const result = flattenTerminalQuickCommand({
|
||||
id: 'test',
|
||||
|
||||
@@ -9,68 +9,6 @@ import {
|
||||
import { folderWorkspaceKey } from './workspace-scope'
|
||||
|
||||
describe('resolveTerminalStartupCwd', () => {
|
||||
it('accepts absolute child paths inside the worktree', () => {
|
||||
expect(resolveTerminalStartupCwd('/repo/app', '/repo/app/packages/web')).toBe(
|
||||
'/repo/app/packages/web'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves relative paths against the worktree', () => {
|
||||
expect(resolveTerminalStartupCwd('/repo/app', 'packages/web')).toBe('/repo/app/packages/web')
|
||||
})
|
||||
|
||||
it('allows absolute cwds outside the worktree (#7685)', () => {
|
||||
expect(resolveTerminalStartupCwd('/repo/app', '/repo/app-other')).toBe('/repo/app-other')
|
||||
})
|
||||
|
||||
it('resolves parent traversal to a path outside the worktree (#7685)', () => {
|
||||
expect(resolveTerminalStartupCwd('/repo/app', '../other')).toBe('/repo/other')
|
||||
})
|
||||
|
||||
it('trims whitespace-padded requested cwds before resolving', () => {
|
||||
expect(resolveTerminalStartupCwd('/repo/app', ' packages/web ')).toBe('/repo/app/packages/web')
|
||||
})
|
||||
|
||||
it('returns undefined for an empty requested cwd', () => {
|
||||
expect(resolveTerminalStartupCwd('/repo/app', '')).toBeUndefined()
|
||||
expect(resolveTerminalStartupCwd('/repo/app', ' ')).toBeUndefined()
|
||||
expect(resolveTerminalStartupCwd('/repo/app', null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('normalizes Windows separators and allows out-of-worktree drives', () => {
|
||||
expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'packages\\web')).toBe(
|
||||
'C:/Repo/App/packages/web'
|
||||
)
|
||||
expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'C:\\Repo\\AppOther')).toBe(
|
||||
'C:/Repo/AppOther'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves renderer PTY cwd values against raw worktree IDs', () => {
|
||||
expect(
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
workspaceId: 'repo-1::/repo/app',
|
||||
requestedCwd: '/repo/app/packages/web'
|
||||
})
|
||||
).toBe('/repo/app/packages/web')
|
||||
expect(
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
workspaceId: 'repo-1::/repo/app',
|
||||
requestedCwd: '/repo/app-other'
|
||||
})
|
||||
).toBe('/repo/app-other')
|
||||
})
|
||||
|
||||
it('passes floating terminal cwds through untouched', () => {
|
||||
// Main validates floating cwds against trusted-directory grants; there is no workspace root to anchor them.
|
||||
expect(
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
workspaceId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
requestedCwd: '/Volumes/work/notes'
|
||||
})
|
||||
).toBe('/Volumes/work/notes')
|
||||
})
|
||||
|
||||
it('falls back to the provider default when no workspace root is resolvable', () => {
|
||||
expect(
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
@@ -152,26 +90,6 @@ describe('resolveTerminalStartupCwd', () => {
|
||||
expect(directoryExists).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back from a missing parent-traversal cwd to the workspace root', () => {
|
||||
expect(
|
||||
resolveTerminalStartupCwd('/repo/app', '../deleted', {
|
||||
directoryExists: (path) => path === '/repo/app'
|
||||
})
|
||||
).toBe('/repo/app')
|
||||
})
|
||||
|
||||
it('recovers missing renderer cwd values against raw worktree IDs', () => {
|
||||
expect(
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
workspaceId: 'repo-1::/repo/app',
|
||||
requestedCwd: '/repo/app/deleted-folder',
|
||||
missingDirFallback: {
|
||||
directoryExists: (path) => path === '/repo/app'
|
||||
}
|
||||
})
|
||||
).toBe('/repo/app')
|
||||
})
|
||||
|
||||
it('recovers missing cwd values against a resolved folder workspace root', () => {
|
||||
expect(
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
@@ -196,23 +114,6 @@ describe('resolveTerminalStartupCwd', () => {
|
||||
).toBe('/Volumes/work/notes')
|
||||
expect(directoryExists).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves renderer PTY cwd values against folder workspace keys', () => {
|
||||
expect(
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
workspaceId: folderWorkspaceKey('folder-1'),
|
||||
requestedCwd: 'packages/web',
|
||||
resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null)
|
||||
})
|
||||
).toBe('/repo/app/packages/web')
|
||||
expect(
|
||||
resolveTerminalStartupCwdForWorkspace({
|
||||
workspaceId: folderWorkspaceKey('folder-1'),
|
||||
requestedCwd: '../other',
|
||||
resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null)
|
||||
})
|
||||
).toBe('/repo/other')
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestsCwdOutsideWorkspaceRoot', () => {
|
||||
@@ -256,11 +157,6 @@ describe('requestsCwdOutsideWorkspaceRoot', () => {
|
||||
expect(requestsCwdOutsideWorkspaceRoot(root, cwd)).toBe(true)
|
||||
})
|
||||
|
||||
it('reads no cwd, or only whitespace, as no custom cwd', () => {
|
||||
expect(requestsCwdOutsideWorkspaceRoot('/repo/app', undefined)).toBe(false)
|
||||
expect(requestsCwdOutsideWorkspaceRoot('/repo/app', ' ')).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a cwd against an unknown root as custom, because it cannot prove otherwise', () => {
|
||||
expect(requestsCwdOutsideWorkspaceRoot(undefined, '/repo/app')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
expandWindowsEnvironmentVariables,
|
||||
expandWindowsPathEnvironmentVariables
|
||||
} from './windows-environment-expansion'
|
||||
|
||||
/** The per-miss `Object.keys().find()` form the lazy index replaced; the differential oracle. */
|
||||
function referenceExpand(value: string, env: Readonly<Record<string, string | undefined>>): string {
|
||||
return value.replace(/%([^%]+)%/g, (match, name: string) => {
|
||||
const exactValue = env[name]
|
||||
if (typeof exactValue === 'string') {
|
||||
return exactValue
|
||||
}
|
||||
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase())
|
||||
const fallback = key ? env[key] : undefined
|
||||
return typeof fallback === 'string' ? fallback : match
|
||||
})
|
||||
}
|
||||
|
||||
function countingEnv(source: Record<string, string | undefined>): {
|
||||
env: Record<string, string | undefined>
|
||||
enumerations: () => number
|
||||
} {
|
||||
let enumerations = 0
|
||||
const env = new Proxy(source, {
|
||||
ownKeys(target) {
|
||||
enumerations += 1
|
||||
return Reflect.ownKeys(target)
|
||||
}
|
||||
})
|
||||
return { env, enumerations: () => enumerations }
|
||||
}
|
||||
|
||||
describe('expandWindowsEnvironmentVariables', () => {
|
||||
it('expands names case-insensitively and preserves unknown variables', () => {
|
||||
expect(
|
||||
expandWindowsEnvironmentVariables('%localappdata%\\agy\\bin;%MISSING%\\bin', {
|
||||
LOCALAPPDATA: 'C:\\Users\\orca\\AppData\\Local'
|
||||
})
|
||||
).toBe('C:\\Users\\orca\\AppData\\Local\\agy\\bin;%MISSING%\\bin')
|
||||
})
|
||||
|
||||
it('expands variables with empty values', () => {
|
||||
expect(expandWindowsEnvironmentVariables('before%EMPTY%after', { EMPTY: '' })).toBe(
|
||||
'beforeafter'
|
||||
)
|
||||
})
|
||||
|
||||
it('enumerates fallback keys once for a PATH containing repeated mixed-case variables', () => {
|
||||
const { env, enumerations } = countingEnv({ ROOT: 'C:\\root', OTHER: 'unused' })
|
||||
const value = Array.from({ length: 1000 }, () => '%root%').join(';')
|
||||
expect(expandWindowsEnvironmentVariables(value, env)).toBe(
|
||||
Array(1000).fill('C:\\root').join(';')
|
||||
)
|
||||
expect(enumerations()).toBe(1)
|
||||
})
|
||||
|
||||
it('never enumerates when every name resolves by exact case', () => {
|
||||
const { env, enumerations } = countingEnv({ ROOT: 'C:\\root', EMPTY: '' })
|
||||
expect(expandWindowsEnvironmentVariables('%ROOT%;%EMPTY%;plain', env)).toBe('C:\\root;;plain')
|
||||
expect(enumerations()).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves exact casing authority and first fallback keys including undefined values', () => {
|
||||
const env = { Root: undefined, ROOT: 'upper', root: 'lower' }
|
||||
expect(expandWindowsEnvironmentVariables('%ROOT%:%root%:%rOoT%', env)).toBe(
|
||||
'upper:lower:%rOoT%'
|
||||
)
|
||||
})
|
||||
|
||||
// Case-insensitive fallback keeps the first insertion-order variant even if another casing shadows it.
|
||||
it('resolves an inexact name to the first case variant, not the last', () => {
|
||||
const env = { Path: 'first', PATH: 'second', pAtH: 'third' }
|
||||
expect(expandWindowsEnvironmentVariables('%PaTh%', env)).toBe('first')
|
||||
})
|
||||
|
||||
it('does not resolve names from the prototype chain', () => {
|
||||
for (const name of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
|
||||
expect(expandWindowsEnvironmentVariables(`%${name}%`, { ROOT: 'x' })).toBe(`%${name}%`)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the per-miss lookup oracle across randomized mixed-case environments', () => {
|
||||
const names = ['PATH', 'Path', 'path', 'pAtH', 'ROOT', 'root', 'Temp', 'TEMP', 'MISSING']
|
||||
const values = ['a', '', 'C:\\x', undefined]
|
||||
let seed = 0x9e3779b9
|
||||
const next = (): number => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff
|
||||
for (let index = 0; index < 4000; index += 1) {
|
||||
const env: Record<string, string | undefined> = {}
|
||||
for (let entry = 0; entry < Math.floor(next() * 5); entry += 1) {
|
||||
env[names[Math.floor(next() * names.length)]!] = values[Math.floor(next() * values.length)]
|
||||
}
|
||||
const value = Array.from(
|
||||
{ length: Math.floor(next() * 6) },
|
||||
() => `%${names[Math.floor(next() * names.length)]}%`
|
||||
).join(';')
|
||||
expect(expandWindowsEnvironmentVariables(value, env)).toBe(referenceExpand(value, env))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandWindowsPathEnvironmentVariables', () => {
|
||||
it('expands every Windows PATH casing without changing other variables', () => {
|
||||
const env = {
|
||||
ORCA_PATH_ROOT: 'C:\\Users\\orca',
|
||||
Path: '%ORCA_PATH_ROOT%\\bin',
|
||||
PATH: '%orca_path_root%\\tools',
|
||||
TEMPLATE: '%ORCA_PATH_ROOT%\\template'
|
||||
}
|
||||
|
||||
expandWindowsPathEnvironmentVariables(env, 'win32')
|
||||
|
||||
expect(env.Path).toBe('C:\\Users\\orca\\bin')
|
||||
expect(env.PATH).toBe('C:\\Users\\orca\\tools')
|
||||
expect(env.TEMPLATE).toBe('%ORCA_PATH_ROOT%\\template')
|
||||
})
|
||||
|
||||
it('leaves non-Windows PATH values unchanged', () => {
|
||||
const env = { ROOT: '/opt/orca', PATH: '%ROOT%/bin:/usr/bin' }
|
||||
|
||||
expandWindowsPathEnvironmentVariables(env, 'linux')
|
||||
|
||||
expect(env.PATH).toBe('%ROOT%/bin:/usr/bin')
|
||||
})
|
||||
})
|
||||
@@ -30,25 +30,6 @@ describe('source-aware reference deltas', () => {
|
||||
expect(getWorkspaceAttachments(removed)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an unknown-source link when several same-number URLs could own it', () => {
|
||||
const other = { ...legacy, url: 'https://github.com/other/repo/pull/42' }
|
||||
expect(getWorkspaceAttachments({ linkedItems: [legacy, foreign, other] })).toEqual([
|
||||
legacy,
|
||||
foreign,
|
||||
other
|
||||
])
|
||||
})
|
||||
|
||||
it('merges observations from an unknown-source link into the only same-number URL', () => {
|
||||
const original = { linkedItems: [{ ...legacy, origins: [first] }] }
|
||||
const updated = normalizeWorkspaceAttachmentUpdate(original, {
|
||||
linkedItemsBase: original.linkedItems,
|
||||
linkedItems: [...original.linkedItems, foreign]
|
||||
})
|
||||
expect(updated.linkedItems).toEqual([{ ...foreign, origins: [first] }])
|
||||
expect(getWorkspaceAttachments(updated)).toEqual(updated.linkedItems)
|
||||
})
|
||||
|
||||
it('does not resurrect a stored unknown-source duplicate removed by an older client', () => {
|
||||
const stored = { linkedItems: [legacy, foreign] }
|
||||
const updated = normalizeWorkspaceAttachmentUpdate(stored, {
|
||||
@@ -92,28 +73,6 @@ describe('source-aware reference deltas', () => {
|
||||
expect(updated.linkedItems).toEqual([{ ...current, origins: [first, second] }])
|
||||
})
|
||||
|
||||
it('does not resurrect canonical links removed during metadata enrichment', () => {
|
||||
const updated = normalizeWorkspaceAttachmentUpdate(
|
||||
{ linkedItems: [] },
|
||||
{
|
||||
linkedItemsBase: [{ ...foreign, repoId: 'repo-a' }],
|
||||
linkedItems: [{ ...foreign, repoId: 'repo-b' }]
|
||||
}
|
||||
)
|
||||
expect(updated.linkedItems).toEqual([])
|
||||
})
|
||||
|
||||
it('deduplicates stored canonical references and merges their observations', () => {
|
||||
expect(
|
||||
getWorkspaceAttachments({
|
||||
linkedItems: [
|
||||
{ ...foreign, repoId: 'repo-a', origins: [first] },
|
||||
{ ...foreign, repoId: 'repo-b', origins: [second] }
|
||||
]
|
||||
})
|
||||
).toEqual([{ ...foreign, repoId: 'repo-a', origins: [first, second] }])
|
||||
})
|
||||
|
||||
it('does not duplicate rich compatibility links whose local context differs', () => {
|
||||
expect(
|
||||
getWorkspaceAttachments({
|
||||
@@ -130,18 +89,6 @@ describe('source-aware reference deltas', () => {
|
||||
).toEqual([foreign])
|
||||
})
|
||||
|
||||
it('collapses an unknown-source link through a full collection write', () => {
|
||||
const updated = normalizeWorkspaceAttachmentUpdate(
|
||||
{ linkedPR: 42 },
|
||||
{ linkedItems: [legacy, foreign] }
|
||||
)
|
||||
expect(getWorkspaceAttachments(updated)).toEqual([foreign])
|
||||
})
|
||||
|
||||
it('returns one reference for a legacy scalar and its matching URL', () => {
|
||||
expect(getWorkspaceAttachments({ linkedPR: 42, linkedItems: [foreign] })).toEqual([foreign])
|
||||
})
|
||||
|
||||
it('collapses stored unknown-source duplicates on unrelated scalar writes', () => {
|
||||
const updated = normalizeWorkspaceAttachmentUpdate(
|
||||
{ linkedItems: [legacy, foreign] },
|
||||
|
||||
@@ -2,8 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceAttachment } from './worktree/types'
|
||||
import {
|
||||
getWorkspaceAttachments,
|
||||
normalizeWorkspaceAttachmentUpdate,
|
||||
workspaceAttachmentReferenceLabel
|
||||
normalizeWorkspaceAttachmentUpdate
|
||||
} from './workspace-attachments'
|
||||
import {
|
||||
getWorkspaceAttachmentKey,
|
||||
@@ -36,20 +35,6 @@ describe('workspace attachments', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reads old metadata and enriches matching collection entries without duplicate compatibility links', () => {
|
||||
const item = {
|
||||
...pr(7),
|
||||
title: 'Seven',
|
||||
url: 'https://github.com/a/repo/pull/7',
|
||||
repoId: 'repo-a'
|
||||
}
|
||||
expect(getWorkspaceAttachments({ linkedPR: 7, linkedItems: [item] })).toEqual([item])
|
||||
expect(getWorkspaceAttachments({ linkedPR: 7, linkedIssue: 8 })).toEqual([
|
||||
{ provider: 'github', type: 'issue', number: 8 },
|
||||
pr(7)
|
||||
])
|
||||
})
|
||||
|
||||
it('retains previous attachments when replacing an active scalar and removes only the explicitly unlinked review', () => {
|
||||
const first = normalizeWorkspaceAttachmentUpdate({ linkedPR: 7 }, { linkedPR: 8 })
|
||||
expect(first.linkedItems).toEqual([pr(7), pr(8)])
|
||||
@@ -73,16 +58,6 @@ describe('workspace attachments', () => {
|
||||
expect(getWorkspaceAttachments(cleared)).toEqual([])
|
||||
})
|
||||
|
||||
it('honors a newly selected review across providers', () => {
|
||||
const mr: WorkspaceAttachment = { provider: 'gitlab', type: 'mr', number: 20 }
|
||||
const updated = normalizeWorkspaceAttachmentUpdate(
|
||||
{ linkedPR: 7, linkedItems: [pr(7), mr] },
|
||||
{ linkedItems: [pr(7), mr], linkedGitLabMR: 20 }
|
||||
)
|
||||
expect(updated.linkedPR).toBeNull()
|
||||
expect(updated.linkedGitLabMR).toBe(20)
|
||||
})
|
||||
|
||||
it('repairs malformed entries and duplicates while retaining reviews from separate URL/repository scopes', () => {
|
||||
const one = { ...pr(7), url: 'https://github.com/A/one/pull/7' }
|
||||
const two = { ...pr(7), url: 'https://github.com/A/two/pull/7' }
|
||||
@@ -102,23 +77,6 @@ describe('workspace attachments', () => {
|
||||
expect(normalizeWorkspaceAttachments([pr(7), one])).toEqual([one])
|
||||
})
|
||||
|
||||
it('keeps Linear issue identities distinct across organizations', () => {
|
||||
const one: WorkspaceAttachment = {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
identifier: 'APP-1',
|
||||
linearOrganizationUrlKey: 'one'
|
||||
}
|
||||
const two = { ...one, linearOrganizationUrlKey: 'two' }
|
||||
expect(normalizeWorkspaceAttachments([one, two])).toEqual([one, two])
|
||||
})
|
||||
|
||||
it('leaves unrelated metadata writes untouched', () => {
|
||||
const updates = { comment: 'Ready for review' }
|
||||
expect(normalizeWorkspaceAttachmentUpdate({ linkedPR: 7 }, updates)).toBe(updates)
|
||||
})
|
||||
|
||||
it('clears the other compatibility representation of an unlinked rich task', () => {
|
||||
const item = {
|
||||
provider: 'github' as const,
|
||||
@@ -285,18 +243,6 @@ describe('workspace attachments', () => {
|
||||
).toEqual([enriched, other])
|
||||
})
|
||||
|
||||
it('keeps title and terminal observations when a richer identity replaces a duplicate', () => {
|
||||
const origin = { kind: 'observed' as const, tabId: 'terminal' }
|
||||
expect(
|
||||
normalizeWorkspaceAttachments([
|
||||
{ ...pr(7), title: 'Seven', origins: [origin] },
|
||||
{ ...pr(7), url: 'https://github.com/a/repo/pull/7' }
|
||||
])
|
||||
).toEqual([
|
||||
{ ...pr(7), title: 'Seven', origins: [origin], url: 'https://github.com/a/repo/pull/7' }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves concurrent origins when both editors enrich an unscoped reference', () => {
|
||||
const first = { kind: 'observed' as const, tabId: 'first' }
|
||||
const second = { kind: 'observed' as const, tabId: 'second' }
|
||||
@@ -344,27 +290,6 @@ describe('workspace attachments', () => {
|
||||
).linkedItems
|
||||
).toEqual([other, first])
|
||||
})
|
||||
|
||||
it('collapses enrichment chains in one normalization without losing origins', () => {
|
||||
const origin = { kind: 'observed' as const, tabId: 'first' }
|
||||
const withUrl = { ...pr(7), url: 'https://github.com/a/repo/pull/7' }
|
||||
const scoped = {
|
||||
...withUrl,
|
||||
taskSourceContext: {
|
||||
provider: 'github' as const,
|
||||
hostId: 'local' as const,
|
||||
projectId: 'project'
|
||||
}
|
||||
}
|
||||
const normalized = normalizeWorkspaceAttachments([
|
||||
{ ...pr(7), origins: [origin] },
|
||||
withUrl,
|
||||
scoped
|
||||
])
|
||||
expect(normalized).toHaveLength(1)
|
||||
expect(normalized[0]).toMatchObject({ ...scoped, origins: [origin] })
|
||||
expect(normalizeWorkspaceAttachments(normalized)).toEqual(normalized)
|
||||
})
|
||||
it('keeps an explicitly empty selection when incompatible references remain', () => {
|
||||
const mr: WorkspaceAttachment = { provider: 'gitlab', type: 'mr', number: 2 }
|
||||
const result = normalizeWorkspaceAttachmentUpdate(
|
||||
@@ -385,34 +310,3 @@ describe('workspace attachments', () => {
|
||||
expect(result.linkedGitLabMR).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceAttachmentReferenceLabel', () => {
|
||||
it('reads a reference as the desktop card and the phone details both show it', () => {
|
||||
expect(workspaceAttachmentReferenceLabel({ provider: 'github', type: 'pr', number: 12 })).toBe(
|
||||
'PR #12'
|
||||
)
|
||||
expect(workspaceAttachmentReferenceLabel({ provider: 'gitlab', type: 'mr', number: 3 })).toBe(
|
||||
'MR !3'
|
||||
)
|
||||
expect(
|
||||
workspaceAttachmentReferenceLabel({ provider: 'github', type: 'issue', number: 4 })
|
||||
).toBe('Issue #4')
|
||||
expect(
|
||||
workspaceAttachmentReferenceLabel({
|
||||
provider: 'jira',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
jiraIdentifier: 'ORCA-4'
|
||||
})
|
||||
).toBe('ORCA-4')
|
||||
expect(
|
||||
workspaceAttachmentReferenceLabel({ provider: 'linear', type: 'issue', number: 9 })
|
||||
).toBe('#9')
|
||||
expect(
|
||||
workspaceAttachmentReferenceLabel(
|
||||
{ provider: 'github', type: 'pr', number: 12 },
|
||||
{ pr: (n) => `Demande #${n}`, mr: String, issue: String }
|
||||
)
|
||||
).toBe('Demande #12')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyJiraIssueKeyCasing,
|
||||
getLinearIssueWorkspaceName,
|
||||
getLinkedWorkItemSuggestedName,
|
||||
getLinkedWorkItemWorkspaceName,
|
||||
getWorkspaceIntentName,
|
||||
resolveWorkspaceCreateName,
|
||||
slugifyForWorkspaceName
|
||||
} from './workspace-name'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('slugifyForWorkspaceName', () => {
|
||||
it('keeps workspace seed slugs short, ascii-safe, and git-ref-safe', () => {
|
||||
expect(slugifyForWorkspaceName('../../Fix mobile Tasks 🚀')).toBe('fix-mobile-tasks')
|
||||
@@ -28,60 +22,9 @@ describe('slugifyForWorkspaceName', () => {
|
||||
'cant-enable-browser-notifications'
|
||||
)
|
||||
})
|
||||
|
||||
it('folds pasted workspace-name whitespace without regex replacement', () => {
|
||||
const replace = vi.spyOn(String.prototype, 'replace')
|
||||
const name = ['Fix', String.fromCharCode(160), '\nPasted\tWorkspace'].join('')
|
||||
|
||||
expect(slugifyForWorkspaceName(name)).toBe('fix-pasted-workspace')
|
||||
expect(
|
||||
replace.mock.calls.filter(
|
||||
([pattern]) => pattern instanceof RegExp && pattern.source === '\\s+'
|
||||
)
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinkedWorkItemSuggestedName', () => {
|
||||
it('removes duplicated issue and PR numbers from linked titles', () => {
|
||||
expect(getLinkedWorkItemSuggestedName({ title: 'Issue #123: Fix mobile Tasks' })).toBe(
|
||||
'fix-mobile-tasks'
|
||||
)
|
||||
expect(getLinkedWorkItemSuggestedName({ title: 'Add mobile drawer (#812)' })).toBe(
|
||||
'add-mobile-drawer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinkedWorkItemWorkspaceName', () => {
|
||||
it('uses the resolved GitHub title instead of the source URL or provider number', () => {
|
||||
expect(
|
||||
getLinkedWorkItemWorkspaceName({
|
||||
type: 'pr',
|
||||
number: 2049,
|
||||
title: 'Fix pasted URL workspace names'
|
||||
})
|
||||
).toEqual({
|
||||
displayName: 'Fix pasted URL workspace names',
|
||||
seedName: 'fix-pasted-url-workspace-names'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps external provider identifiers without duplicating title prefixes', () => {
|
||||
expect(
|
||||
getLinkedWorkItemWorkspaceName({
|
||||
type: 'issue',
|
||||
provider: 'jira',
|
||||
number: 0,
|
||||
title: 'PROJ-7 Fix flaky import',
|
||||
jiraIdentifier: 'PROJ-7'
|
||||
})
|
||||
).toEqual({
|
||||
displayName: 'PROJ-7 Fix flaky import',
|
||||
seedName: 'proj-7-fix-flaky-import'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the canonical uppercase Jira key when the casing policy preserves it', () => {
|
||||
const item = {
|
||||
type: 'issue' as const,
|
||||
@@ -98,21 +41,6 @@ describe('getLinkedWorkItemWorkspaceName', () => {
|
||||
getLinkedWorkItemWorkspaceName(item, { jiraIssueKeyCasing: 'lowercase' })?.seedName
|
||||
).toBe('proj-7-fix-flaky-import')
|
||||
})
|
||||
|
||||
it('ignores the Jira casing policy for non-Jira work items', () => {
|
||||
expect(
|
||||
getLinkedWorkItemWorkspaceName(
|
||||
{
|
||||
type: 'issue',
|
||||
provider: 'linear',
|
||||
number: 0,
|
||||
title: 'Fix it',
|
||||
linearIdentifier: 'ENG-1'
|
||||
},
|
||||
{ jiraIssueKeyCasing: 'preserve' }
|
||||
)?.seedName
|
||||
).toBe('eng-1-fix-it')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Jira generated-name source identity', () => {
|
||||
@@ -145,28 +73,6 @@ describe('Jira generated-name source identity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyJiraIssueKeyCasing', () => {
|
||||
it('uppercases any Jira project key shape without an allowlist', () => {
|
||||
expect(applyJiraIssueKeyCasing('alt-42-title', 'ALT-42', 'preserve')).toBe('ALT-42-title')
|
||||
expect(applyJiraIssueKeyCasing('team_alpha9-7-title', 'TEAM_ALPHA9-7', 'preserve')).toBe(
|
||||
'TEAM_ALPHA9-7-title'
|
||||
)
|
||||
expect(applyJiraIssueKeyCasing('proj-123', 'proj-123', 'preserve')).toBe('PROJ-123')
|
||||
})
|
||||
|
||||
it('leaves the seed alone for the default policy or a non-leading key', () => {
|
||||
expect(applyJiraIssueKeyCasing('proj-123-title', 'PROJ-123', undefined)).toBe('proj-123-title')
|
||||
expect(applyJiraIssueKeyCasing('proj-123-title', 'PROJ-123', 'lowercase')).toBe(
|
||||
'proj-123-title'
|
||||
)
|
||||
expect(applyJiraIssueKeyCasing('proj-1234-title', 'PROJ-123', 'preserve')).toBe(
|
||||
'proj-1234-title'
|
||||
)
|
||||
expect(applyJiraIssueKeyCasing('fix-proj-123', 'PROJ-123', 'preserve')).toBe('fix-proj-123')
|
||||
expect(applyJiraIssueKeyCasing('title', 'not a key', 'preserve')).toBe('title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceIntentName', () => {
|
||||
it('uses explicit user intent for linked issues without copying long titles', () => {
|
||||
expect(
|
||||
@@ -215,68 +121,6 @@ describe('getWorkspaceIntentName', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a compressed subject when a linked issue has no action', () => {
|
||||
expect(
|
||||
getWorkspaceIntentName({
|
||||
sourceText: 'https://github.com/acme/app/issues/9876',
|
||||
workItem: {
|
||||
type: 'issue',
|
||||
number: 9876,
|
||||
title: 'Make importer handle archived rows'
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayName: 'Issue 9876 Make Importer Handle',
|
||||
seedName: 'issue-9876-make-importer-handle'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps contractions readable in linked issue display names', () => {
|
||||
expect(
|
||||
getWorkspaceIntentName({
|
||||
sourceText: 'https://github.com/acme/app/issues/4802',
|
||||
workItem: {
|
||||
type: 'issue',
|
||||
number: 4802,
|
||||
title: "Can't enable browser notifications from within a browser tab"
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayName: "Issue 4802 Can't Enable Browser",
|
||||
seedName: 'issue-4802-cant-enable-browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps single-letter contractions lowercase after the apostrophe', () => {
|
||||
expect(
|
||||
getWorkspaceIntentName({
|
||||
sourceText: 'https://github.com/acme/app/issues/17',
|
||||
workItem: {
|
||||
type: 'issue',
|
||||
number: 17,
|
||||
title: "i'm blocked on notifications"
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayName: "Issue 17 I'm Blocked Notifications",
|
||||
seedName: 'issue-17-im-blocked-notifications'
|
||||
})
|
||||
|
||||
expect(
|
||||
getWorkspaceIntentName({
|
||||
sourceText: 'https://github.com/acme/app/issues/18',
|
||||
workItem: {
|
||||
type: 'issue',
|
||||
number: 18,
|
||||
title: "i'll update login"
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayName: "Issue 18 I'll Update Login",
|
||||
seedName: 'issue-18-ill-update-login'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat an auto-generated slug as explicit user intent', () => {
|
||||
expect(
|
||||
getWorkspaceIntentName({
|
||||
@@ -292,69 +136,9 @@ describe('getWorkspaceIntentName', () => {
|
||||
seedName: 'issue-456-make-importer-handle'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses external provider identifiers without duplicating them in the subject', () => {
|
||||
expect(
|
||||
getWorkspaceIntentName({
|
||||
workItem: {
|
||||
type: 'issue',
|
||||
provider: 'jira',
|
||||
number: 0,
|
||||
title: 'PROJ-7 Fix flaky import',
|
||||
jiraIdentifier: 'PROJ-7'
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayName: 'PROJ-7 Fix Flaky Import',
|
||||
seedName: 'proj-7-fix-flaky-import'
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes unlinked task text into a shared display and seed', () => {
|
||||
expect(getWorkspaceIntentName({ sourceText: 'add keyboard shortcut settings' })).toEqual({
|
||||
displayName: 'Add Keyboard Shortcut Settings',
|
||||
seedName: 'add-keyboard-shortcut-settings'
|
||||
})
|
||||
})
|
||||
|
||||
it('compacts pasted task text without whitespace regex splitting', () => {
|
||||
const split = vi.spyOn(String.prototype, 'split')
|
||||
const sourceText = [
|
||||
'https://github.com/acme/app/issues/123',
|
||||
'\nadd',
|
||||
String.fromCharCode(160),
|
||||
'keyboard\tshortcut settings'
|
||||
].join('')
|
||||
|
||||
expect(getWorkspaceIntentName({ sourceText })).toEqual({
|
||||
displayName: 'Add Keyboard Shortcut Settings',
|
||||
seedName: 'add-keyboard-shortcut-settings'
|
||||
})
|
||||
expect(
|
||||
split.mock.calls.filter(([pattern]) => pattern instanceof RegExp && pattern.source === '\\s+')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinearIssueWorkspaceName', () => {
|
||||
it('keeps the Linear identifier in the workspace seed', () => {
|
||||
expect(
|
||||
getLinearIssueWorkspaceName({
|
||||
identifier: 'ENG-42',
|
||||
title: 'Ship Linear parity'
|
||||
})
|
||||
).toBe('eng-42-ship-linear-parity')
|
||||
})
|
||||
|
||||
it('does not duplicate an identifier already present in the Linear title', () => {
|
||||
expect(
|
||||
getLinearIssueWorkspaceName({
|
||||
identifier: 'ENG-42',
|
||||
title: 'ENG-42 Ship Linear parity'
|
||||
})
|
||||
).toBe('eng-42-ship-linear-parity')
|
||||
})
|
||||
|
||||
it('keeps the combined Linear seed within the workspace-name limit', () => {
|
||||
const seed = getLinearIssueWorkspaceName({
|
||||
identifier: 'ENG-42',
|
||||
@@ -380,9 +164,4 @@ describe('resolveWorkspaceCreateName', () => {
|
||||
})
|
||||
).toBe('日本語 テスト')
|
||||
})
|
||||
|
||||
it('uses the stable fallback when the draft is blank', () => {
|
||||
expect(resolveWorkspaceCreateName({ draft: ' ', fallback: 'pr-9' })).toBe('pr-9')
|
||||
expect(resolveWorkspaceCreateName({ draft: undefined, fallback: 'issue-4' })).toBe('issue-4')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,12 +111,6 @@ describe('reference URLs', () => {
|
||||
])('rejects ambiguous or invalid input %s', (input) => {
|
||||
expect(() => parseWorkspaceReferenceUrl(input)).toThrow()
|
||||
})
|
||||
|
||||
it('accepts a known provider hint for self-hosted issue metadata', () => {
|
||||
expect(
|
||||
parseWorkspaceReferenceUrl('https://git.example/acme/api/issues/42', 'gitea').provider
|
||||
).toBe('gitea')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reference identity', () => {
|
||||
@@ -126,17 +120,6 @@ describe('reference identity', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['https://www.github.com/acme/api/pull/42', 'https://github.com/acme/api/pull/42'],
|
||||
[
|
||||
'https://gitlab.com/Group/Api/-/merge_requests/42',
|
||||
'https://gitlab.com/group/api/-/merge_requests/42'
|
||||
],
|
||||
['https://codeberg.org/Acme/Api/pulls/42', 'https://codeberg.org/acme/api/pulls/42']
|
||||
])('treats case-insensitive host routes as one source: %s vs %s', (a, b) => {
|
||||
expect(identity(a)).toBe(identity(b))
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://github.com/ACME/Api/pull/42',
|
||||
'https://git.example/Root/Group/Api/-/merge_requests/42',
|
||||
@@ -174,13 +157,6 @@ describe('reference identity', () => {
|
||||
expect(identity(a)).not.toBe(identity(b))
|
||||
})
|
||||
|
||||
it('does not let local repository IDs split a proven external identity', () => {
|
||||
const reference = parseWorkspaceReferenceUrl('https://github.com/acme/api/pull/42')
|
||||
expect(getWorkspaceReferenceIdentity({ ...reference, repoId: 'repo-a' })).toBe(
|
||||
getWorkspaceReferenceIdentity({ ...reference, repoId: 'repo-b' })
|
||||
)
|
||||
})
|
||||
|
||||
it('matches URL-less entries only when stored provider metadata proves the source', () => {
|
||||
const item: WorkspaceAttachment = {
|
||||
provider: 'github',
|
||||
|
||||
@@ -20,16 +20,6 @@ describe('resolveWorktreeExecutionHost', () => {
|
||||
).toEqual({ kind: 'resolved', hostId: 'ssh:m4air', connectionId: 'm4air', owner: null })
|
||||
})
|
||||
|
||||
it('answers before the repo row hydrates, because the host is not a guess', () => {
|
||||
// An explicit host needs no repo hydration to resolve; #6648 blocks unknown hosts only.
|
||||
expect(resolve([], { repoId: 'r', hostId: 'ssh:m4air' })).toEqual({
|
||||
kind: 'resolved',
|
||||
hostId: 'ssh:m4air',
|
||||
connectionId: 'm4air',
|
||||
owner: null
|
||||
})
|
||||
})
|
||||
|
||||
it('picks the row on that host when both SSH hosts carry the id', () => {
|
||||
const openclaw = { id: 'r', connectionId: 'openclaw' }
|
||||
const m4air = { id: 'r', connectionId: 'm4air' }
|
||||
@@ -47,16 +37,6 @@ describe('resolveWorktreeExecutionHost', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a row that names the host in either spelling', () => {
|
||||
const stamped = { id: 'r', executionHostId: 'ssh:m4air' }
|
||||
expect(resolve([stamped], { repoId: 'r', hostId: 'ssh:m4air' })).toEqual({
|
||||
kind: 'resolved',
|
||||
hostId: 'ssh:m4air',
|
||||
connectionId: 'm4air',
|
||||
owner: stamped
|
||||
})
|
||||
})
|
||||
|
||||
it('takes no connection from a row on a different host, whatever this host is', () => {
|
||||
for (const hostId of ['local', 'runtime:env-a']) {
|
||||
expect(resolve([{ id: 'r', connectionId: 'openclaw' }], { repoId: 'r', hostId })).toEqual({
|
||||
@@ -91,30 +71,6 @@ describe('resolveWorktreeExecutionHost', () => {
|
||||
})
|
||||
|
||||
describe('the worktree names no host', () => {
|
||||
it('resolves from the sole row, in either spelling', () => {
|
||||
const legacy = { id: 'r', connectionId: 'openclaw' }
|
||||
expect(resolve([legacy], { repoId: 'r' })).toEqual({
|
||||
kind: 'resolved',
|
||||
hostId: 'ssh:openclaw',
|
||||
connectionId: 'openclaw',
|
||||
owner: legacy
|
||||
})
|
||||
const stamped = { id: 'r', executionHostId: 'ssh:m4air' }
|
||||
expect(resolve([stamped], { repoId: 'r' })).toEqual({
|
||||
kind: 'resolved',
|
||||
hostId: 'ssh:m4air',
|
||||
connectionId: 'm4air',
|
||||
owner: stamped
|
||||
})
|
||||
const local = { id: 'r' }
|
||||
expect(resolve([local], { repoId: 'r' })).toEqual({
|
||||
kind: 'resolved',
|
||||
hostId: 'local',
|
||||
connectionId: null,
|
||||
owner: local
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses when rival rows disagree about the host, including two SSH hosts', () => {
|
||||
expect(
|
||||
resolve(
|
||||
@@ -144,10 +100,6 @@ describe('resolveWorktreeExecutionHost', () => {
|
||||
).toMatchObject({ kind: 'resolved', hostId: 'ssh:m4air', connectionId: 'm4air' })
|
||||
})
|
||||
|
||||
it('reports an unknown owner distinctly from a conflicting one', () => {
|
||||
expect(resolve([], { repoId: 'r' })).toEqual({ kind: 'unresolved', reason: 'unknown' })
|
||||
})
|
||||
|
||||
// Unknown may fall back to a local folder; a declared but unparseable host must fail closed.
|
||||
it('reports a row naming an unparseable host distinctly from an unknown one', () => {
|
||||
for (const executionHostId of ['ssh:', 'ssh:a|b', 'ssh:%zz', 'runtime:', 'quantum:box']) {
|
||||
@@ -165,21 +117,6 @@ describe('resolveWorktreeExecutionHost', () => {
|
||||
})
|
||||
).toEqual({ kind: 'unresolved', reason: 'malformed' })
|
||||
})
|
||||
|
||||
it('still resolves every row that names a parseable host', () => {
|
||||
expect(resolve([{ id: 'r', executionHostId: 'ssh:box' }], { repoId: 'r' })).toMatchObject({
|
||||
kind: 'resolved',
|
||||
hostId: 'ssh:box'
|
||||
})
|
||||
expect(resolve([{ id: 'r', connectionId: 'box' }], { repoId: 'r' })).toMatchObject({
|
||||
kind: 'resolved',
|
||||
hostId: 'ssh:box'
|
||||
})
|
||||
expect(resolve([{ id: 'r' }], { repoId: 'r' })).toMatchObject({
|
||||
kind: 'resolved',
|
||||
hostId: 'local'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores an unparseable host id rather than treating it as a host', () => {
|
||||
@@ -224,31 +161,4 @@ describe('createRepoRowExecutionHostLookup', () => {
|
||||
}
|
||||
expect(idReads()).toBe(afterBuild)
|
||||
})
|
||||
|
||||
it('answers missing, ambiguous and resolved exactly as a per-call scan would', () => {
|
||||
expect(createRepoRowExecutionHostLookup([]).byId('r')).toEqual({ kind: 'missing' })
|
||||
|
||||
const openclaw = { id: 'r', connectionId: 'openclaw' }
|
||||
const m4air = { id: 'r', connectionId: 'm4air' }
|
||||
expect(createRepoRowExecutionHostLookup([openclaw, m4air]).byId('r')).toEqual({
|
||||
kind: 'ambiguous'
|
||||
})
|
||||
|
||||
const first: ExecutionHostOwnerRow = { id: 'r', connectionId: 'm4air' }
|
||||
const second: ExecutionHostOwnerRow = { id: 'r', executionHostId: 'ssh:m4air' }
|
||||
expect(createRepoRowExecutionHostLookup([first, second]).byId('r')).toEqual({
|
||||
kind: 'resolved',
|
||||
owner: first
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps byHost hits, misses and repo-list order', () => {
|
||||
const openclaw = { id: 'r', connectionId: 'openclaw' }
|
||||
const m4air = { id: 'r', connectionId: 'm4air' }
|
||||
const lookup = createRepoRowExecutionHostLookup([openclaw, m4air])
|
||||
expect(lookup.byHost('r', 'ssh:m4air')).toBe(m4air)
|
||||
expect(lookup.byHost('r', 'ssh:openclaw')).toBe(openclaw)
|
||||
expect(lookup.byHost('r', 'local')).toBeNull()
|
||||
expect(lookup.byHost('other', 'local')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WORKTREE_ID_SEPARATOR,
|
||||
composeWorktreeId,
|
||||
getRepoIdFromWorktreeId,
|
||||
getRepoMainWorktreeId,
|
||||
getWorktreePathBasenameFromId,
|
||||
splitWorktreeId,
|
||||
splitWorktreeIdForFilesystem,
|
||||
@@ -11,94 +7,7 @@ import {
|
||||
worktreeIdsEqual
|
||||
} from './id'
|
||||
|
||||
describe('getRepoMainWorktreeId', () => {
|
||||
it('round-trips through the id parsers on posix and Windows paths', () => {
|
||||
for (const repo of [
|
||||
{ id: 'repo-123', path: '/abs/path' },
|
||||
{ id: 'repo-123', path: 'C:\\Users\\me\\repo' }
|
||||
]) {
|
||||
const worktreeId = getRepoMainWorktreeId(repo)
|
||||
expect(worktreeId).toBe(`${repo.id}${WORKTREE_ID_SEPARATOR}${repo.path}`)
|
||||
expect(splitWorktreeId(worktreeId)).toEqual({ repoId: repo.id, worktreePath: repo.path })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeWorktreeId', () => {
|
||||
it('keeps the path spelling exactly as given', () => {
|
||||
for (const path of [
|
||||
'/srv/Repo/',
|
||||
'C:\\Users\\me\\repo',
|
||||
'//wsl$/Ubuntu/home/a',
|
||||
'/private/tmp/x'
|
||||
]) {
|
||||
expect(composeWorktreeId('repo-1', path)).toBe(`repo-1${WORKTREE_ID_SEPARATOR}${path}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepoIdFromWorktreeId', () => {
|
||||
it('returns the repo id for a canonical worktree id', () => {
|
||||
expect(getRepoIdFromWorktreeId('repo-123::/abs/path')).toBe('repo-123')
|
||||
})
|
||||
|
||||
it('returns the whole input when there is no separator', () => {
|
||||
expect(getRepoIdFromWorktreeId('just-a-repo-id')).toBe('just-a-repo-id')
|
||||
})
|
||||
|
||||
it('returns the empty string for an empty input', () => {
|
||||
expect(getRepoIdFromWorktreeId('')).toBe('')
|
||||
})
|
||||
|
||||
it('returns an empty repo id for a bare separator', () => {
|
||||
expect(getRepoIdFromWorktreeId('::')).toBe('')
|
||||
})
|
||||
|
||||
it('returns an empty repo id for a leading separator', () => {
|
||||
expect(getRepoIdFromWorktreeId('::path')).toBe('')
|
||||
})
|
||||
|
||||
it('returns the repo id when only a trailing separator is present', () => {
|
||||
expect(getRepoIdFromWorktreeId('repo::')).toBe('repo')
|
||||
})
|
||||
|
||||
it('splits on the first separator when the path itself contains "::"', () => {
|
||||
expect(getRepoIdFromWorktreeId('repo::a::b')).toBe('repo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitWorktreeId', () => {
|
||||
it('splits a canonical worktree id into repo id and path', () => {
|
||||
expect(splitWorktreeId('repo-123::/abs/path')).toEqual({
|
||||
repoId: 'repo-123',
|
||||
worktreePath: '/abs/path'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when there is no separator', () => {
|
||||
expect(splitWorktreeId('just-a-repo-id')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an empty input', () => {
|
||||
expect(splitWorktreeId('')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty repo id and empty path for a bare separator', () => {
|
||||
expect(splitWorktreeId('::')).toEqual({ repoId: '', worktreePath: '' })
|
||||
})
|
||||
|
||||
it('returns an empty repo id when the separator is leading', () => {
|
||||
expect(splitWorktreeId('::path')).toEqual({ repoId: '', worktreePath: 'path' })
|
||||
})
|
||||
|
||||
it('returns an empty path when the separator is trailing', () => {
|
||||
expect(splitWorktreeId('repo::')).toEqual({ repoId: 'repo', worktreePath: '' })
|
||||
})
|
||||
|
||||
it('splits on the first separator when the path itself contains "::"', () => {
|
||||
expect(splitWorktreeId('repo::a::b')).toEqual({ repoId: 'repo', worktreePath: 'a::b' })
|
||||
})
|
||||
|
||||
it('preserves folder workspace instance suffixes in the literal parsed path', () => {
|
||||
expect(
|
||||
splitWorktreeId('repo::/folder::workspace:123e4567-e89b-12d3-a456-426614174000')
|
||||
@@ -118,18 +27,6 @@ describe('splitWorktreeIdForFilesystem', () => {
|
||||
})
|
||||
|
||||
describe('getWorktreePathBasenameFromId', () => {
|
||||
it('returns the path basename for POSIX worktree ids', () => {
|
||||
expect(getWorktreePathBasenameFromId('repo-123::/abs/path/nightly-checks')).toBe(
|
||||
'nightly-checks'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the path basename for Windows worktree ids', () => {
|
||||
expect(getWorktreePathBasenameFromId('repo-123::C:\\workspaces\\nightly-checks')).toBe(
|
||||
'nightly-checks'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the real folder basename for folder workspace instance ids', () => {
|
||||
expect(
|
||||
getWorktreePathBasenameFromId(
|
||||
@@ -137,11 +34,6 @@ describe('getWorktreePathBasenameFromId', () => {
|
||||
)
|
||||
).toBe('project')
|
||||
})
|
||||
|
||||
it('returns null when no worktree path is available', () => {
|
||||
expect(getWorktreePathBasenameFromId('repo-123')).toBeNull()
|
||||
expect(getWorktreePathBasenameFromId('repo-123::')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/** ID selectors must fold exactly the path spellings path: selectors fold (#16243). */
|
||||
|
||||
@@ -6,7 +6,6 @@ import { resolveConfiguredWorktreeBasePaths } from './configured-worktree-base-p
|
||||
import { createWorktreeVisibilitySourceMatcher } from './visibility-sources'
|
||||
import {
|
||||
buildKnownOrcaWorkspaceLayouts,
|
||||
classifyWorktreeOwnership,
|
||||
toDetectedWorktree,
|
||||
EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT
|
||||
} from './ownership'
|
||||
@@ -71,39 +70,6 @@ describe('a configured worktree base that collides with a built-in visibility so
|
||||
const configuredBaseWorktree = '/repos/OrbisCXM/.claude/worktrees/OrbisCXM/squash-migrations'
|
||||
const tempScratchpadWorktree = '/tmp/claude/abc/scratchpad/sonda-voter'
|
||||
|
||||
it('classifies a plain git worktree in the configured base as external, not agent scratch', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '.claude/worktrees' })
|
||||
const settings = makeSettings()
|
||||
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree(configuredBaseWorktree),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('shows identically-provenanced worktrees the same way wherever they live', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '.claude/worktrees' })
|
||||
|
||||
expect(detect(repo, tempScratchpadWorktree)).toMatchObject({
|
||||
ownership: 'external',
|
||||
visible: true
|
||||
})
|
||||
expect(detect(repo, configuredBaseWorktree)).toMatchObject({
|
||||
ownership: 'external',
|
||||
visible: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not attribute the configured base to the built-in source row', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '.claude/worktrees' })
|
||||
|
||||
expect(detect(repo, configuredBaseWorktree).visibilitySource).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hands the configured base to the project external-worktree setting', () => {
|
||||
const repo = makeRepo({
|
||||
worktreeBasePath: '.claude/worktrees',
|
||||
@@ -184,15 +150,6 @@ describe('a configured worktree base that collides with a built-in visibility so
|
||||
expect(detected.visibilitySource).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves an absolute configured base that matches a built-in source', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '/repos/OrbisCXM/.claude/worktrees' })
|
||||
|
||||
expect(detect(repo, configuredBaseWorktree)).toMatchObject({
|
||||
ownership: 'external',
|
||||
visible: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the configured base external when workspace nesting is flat', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '.claude/worktrees' })
|
||||
|
||||
@@ -208,45 +165,6 @@ describe('a configured worktree base that collides with a built-in visibility so
|
||||
describe('agent scratch stays hidden for repos that did not configure that base (#9388)', () => {
|
||||
const scratchWorktree = '/repos/OrbisCXM/.claude/worktrees/OrbisCXM/agent-a04ccaaa'
|
||||
|
||||
it('hides an unconfigured .claude/worktrees checkout', () => {
|
||||
const detected = detect(makeRepo(), scratchWorktree)
|
||||
|
||||
expect(detected).toMatchObject({
|
||||
ownership: 'agent-scratch',
|
||||
visible: false,
|
||||
visibilitySource: { kind: 'built-in', id: 'claude' }
|
||||
})
|
||||
})
|
||||
|
||||
it('hides it when the configured base points somewhere else', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '../worktrees' })
|
||||
|
||||
expect(detect(repo, scratchWorktree)).toMatchObject({
|
||||
ownership: 'agent-scratch',
|
||||
visible: false
|
||||
})
|
||||
})
|
||||
|
||||
it('hides scratch nested under a linked checkout even with a configured base', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '.claude/worktrees' })
|
||||
const linkedCheckout = '/orca/workspaces/OrbisCXM/feature-x'
|
||||
const settings = makeSettings()
|
||||
|
||||
expect(
|
||||
toDetectedWorktree({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree(`${linkedCheckout}/.claude/worktrees/agent-a04ccaaa`),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo),
|
||||
worktreeVisibilitySourceMatcher: createWorktreeVisibilitySourceMatcher(
|
||||
[repo.path, linkedCheckout],
|
||||
[],
|
||||
resolveConfiguredWorktreeBasePaths(repo)
|
||||
)
|
||||
})
|
||||
).toMatchObject({ ownership: 'agent-scratch', visible: false })
|
||||
})
|
||||
|
||||
it('hides scratch when the configured base only contains the scratch root', () => {
|
||||
for (const worktreeBasePath of ['.', '..', '../worktrees/..']) {
|
||||
expect(detect(makeRepo({ worktreeBasePath }), scratchWorktree)).toMatchObject({
|
||||
@@ -276,13 +194,4 @@ describe('agent scratch stays hidden for repos that did not configure that base
|
||||
})
|
||||
).toMatchObject({ ownership: 'agent-scratch', visible: false })
|
||||
})
|
||||
|
||||
it('hides the other built-in source inside a repo that configured only the claude one', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '.claude/worktrees' })
|
||||
|
||||
expect(detect(repo, '/repos/OrbisCXM/.gsd-workspaces/phase-1')).toMatchObject({
|
||||
ownership: 'agent-scratch',
|
||||
visible: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,34 +30,6 @@ function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
}
|
||||
|
||||
describe('repo-specific worktree ownership layouts', () => {
|
||||
it('lets an explicitly configured Claude base outrank the built-in scratch path', () => {
|
||||
const repo = makeRepo({ worktreeBasePath: '.claude/worktrees' })
|
||||
const settings = makeSettings()
|
||||
const worktree = makeWorktree('/projects/a/repo/.claude/worktrees/repo/feature')
|
||||
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree,
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('keeps the same path classified as scratch without the explicit base', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree('/projects/a/repo/.claude/worktrees/repo/feature'),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('agent-scratch')
|
||||
})
|
||||
|
||||
it('resolves the same relative base path from each repo root', () => {
|
||||
const settings = makeSettings()
|
||||
const repoA = makeRepo({ path: '/projects/a/repo', worktreeBasePath: '../worktrees' })
|
||||
@@ -89,47 +61,6 @@ describe('repo-specific worktree ownership layouts', () => {
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('uses repo-specific nested layouts for Windows-style paths', () => {
|
||||
const repo = makeRepo({
|
||||
path: 'C:\\projects\\App\\repo',
|
||||
worktreeBasePath: '..\\worktrees'
|
||||
})
|
||||
const settings = makeSettings({ workspaceDir: 'D:\\global' })
|
||||
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree('C:\\projects\\App\\worktrees\\repo\\Feature'),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('resolves an absolute Linux base path of a WSL repo into its distro layout (STA-4772)', () => {
|
||||
const repo = makeRepo({
|
||||
path: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\src\\repo',
|
||||
worktreeBasePath: '/home/jin/src/.orca-worktrees'
|
||||
})
|
||||
const settings = makeSettings({ workspaceDir: 'C:\\global' })
|
||||
const layouts = buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
|
||||
expect(layouts[0]).toEqual({
|
||||
path: '//wsl.localhost/Ubuntu-24.04/home/jin/src/.orca-worktrees',
|
||||
nestWorkspaces: true
|
||||
})
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree(
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\src\\.orca-worktrees\\repo\\feature'
|
||||
),
|
||||
knownOrcaLayouts: layouts
|
||||
})
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('classifies worktrees under a dotted Linux base exactly where creation collapses it', () => {
|
||||
const repo = makeRepo({
|
||||
path: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\src\\repo',
|
||||
@@ -154,25 +85,6 @@ describe('repo-specific worktree ownership layouts', () => {
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('preserves mixed-case WSL paths while resolving the configured base', () => {
|
||||
const repo = makeRepo({
|
||||
path: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\Dev\\Repo',
|
||||
worktreeBasePath: '/home/Dev/Repo/.claude/worktrees'
|
||||
})
|
||||
const settings = makeSettings({ workspaceDir: 'C:\\global' })
|
||||
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree(
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\Dev\\Repo\\.claude\\worktrees\\feature'
|
||||
),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('includes relative global layouts for SSH repos without applying absolute desktop paths', () => {
|
||||
const repo = makeRepo({ path: '/remote/repo', connectionId: 'ssh-1' })
|
||||
const relativeSettings = makeSettings({ workspaceDir: '../worktrees' })
|
||||
|
||||
@@ -3,14 +3,12 @@ import type { GlobalSettings } from '../global-settings-types'
|
||||
import type { Repo } from '../repo-types'
|
||||
import type { WorktreeMeta } from './meta-types'
|
||||
import type { Worktree } from './types'
|
||||
import { createAgentScratchWorktreePathMatcher } from '../agent-scratch-worktrees'
|
||||
|
||||
import { migrateExternalWorktreeVisibilityDefaults } from '../external-worktree-visibility'
|
||||
import {
|
||||
applyMetadataFallbackVisibility,
|
||||
buildKnownOrcaWorkspaceLayouts,
|
||||
classifyWorktreeOwnership,
|
||||
effectiveExternalWorktreeVisibility,
|
||||
isLegacyRepoForExternalWorktreeVisibility,
|
||||
shouldShowWorktree,
|
||||
toDetectedWorktree,
|
||||
EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT
|
||||
@@ -99,75 +97,6 @@ function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
}
|
||||
|
||||
describe('worktree ownership classification', () => {
|
||||
it('treats explicit Orca metadata as managed even outside the workspace root', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({ path: '/tmp/outside' }),
|
||||
meta: makeMeta({ orcaCreatedAt: 1 }),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('orca-managed')
|
||||
})
|
||||
|
||||
it('treats nested Orca workspace paths without metadata as external', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
const layouts = buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }),
|
||||
knownOrcaLayouts: layouts
|
||||
})
|
||||
).toBe('external')
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({ path: '/orca/workspaces/other/feature' }),
|
||||
knownOrcaLayouts: layouts
|
||||
})
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('treats explicit Orca creation layout metadata as managed', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }),
|
||||
meta: makeMeta({
|
||||
orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true }
|
||||
}),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('orca-managed')
|
||||
})
|
||||
|
||||
it('does not treat metadata-free nested workspace paths as Orca-managed for new repos', () => {
|
||||
const repo = makeRepo({ externalWorktreeVisibility: 'hide' })
|
||||
const settings = makeSettings()
|
||||
const detected = toDetectedWorktree({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({
|
||||
path: '/orca/workspaces/app/manual-git-worktree',
|
||||
isMainWorktree: false
|
||||
}),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
|
||||
expect(detected.ownership).toBe('external')
|
||||
expect(detected.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat generic discovery metadata on nested workspace paths as Orca-managed', () => {
|
||||
const repo = makeRepo({ externalWorktreeVisibility: 'hide' })
|
||||
const settings = makeSettings()
|
||||
@@ -186,56 +115,6 @@ describe('worktree ownership classification', () => {
|
||||
expect(detected.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps nested workspace paths visible for legacy repos without explicit visibility', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
const detected = toDetectedWorktree({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({
|
||||
path: '/orca/workspaces/app/manual-git-worktree',
|
||||
isMainWorktree: false
|
||||
}),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
|
||||
expect(detected.ownership).toBe('external')
|
||||
expect(detected.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('hides metadata-free nested workspace paths for legacy repos that hide external worktrees', () => {
|
||||
const repo = makeRepo({
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
})
|
||||
const settings = makeSettings()
|
||||
const detected = toDetectedWorktree({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({
|
||||
path: '/orca/workspaces/app/manual-git-worktree',
|
||||
isMainWorktree: false
|
||||
}),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
|
||||
expect(detected.ownership).toBe('external')
|
||||
expect(detected.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('treats flat workspace-root descendants as unknown legacy without strong metadata', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings({ nestWorkspaces: false })
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({ path: '/orca/workspaces/feature' }),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('unknown-legacy')
|
||||
})
|
||||
|
||||
it('keeps flat-layout history weak after switching the same root to nested mode', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings({
|
||||
@@ -252,22 +131,6 @@ describe('worktree ownership classification', () => {
|
||||
).toBe('unknown-legacy')
|
||||
})
|
||||
|
||||
it('uses each historical layout nest mode when matching old roots', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings({
|
||||
workspaceDir: '/new/workspaces',
|
||||
workspaceDirHistory: [{ path: '/old/workspaces', nestWorkspaces: true }]
|
||||
})
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({ path: '/old/workspaces/app/feature' }),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('builds known layouts from large workspace history lists', () => {
|
||||
const repo = makeRepo()
|
||||
const workspaceDirHistory = Array.from(
|
||||
@@ -293,23 +156,6 @@ describe('worktree ownership classification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('handles Windows drive casing and separators', () => {
|
||||
const repo = makeRepo({ path: 'C:\\repos\\App' })
|
||||
const settings = makeSettings({ workspaceDir: 'C:\\Orca\\Workspaces' })
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({
|
||||
id: 'repo-1::C:\\ORCA\\WORKSPACES\\App\\Feature',
|
||||
path: 'C:\\ORCA\\WORKSPACES\\App\\Feature',
|
||||
isMainWorktree: false
|
||||
}),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('external')
|
||||
})
|
||||
|
||||
it('keeps selected linked checkouts visible without trusting Git main-worktree', () => {
|
||||
const repo = makeRepo({ path: '/repos/app-linked', externalWorktreeVisibility: 'hide' })
|
||||
const settings = makeSettings()
|
||||
@@ -339,21 +185,6 @@ describe('worktree ownership classification', () => {
|
||||
})
|
||||
|
||||
describe('external worktree visibility policy', () => {
|
||||
it('defaults undefined visibility to hide for new repos and show for legacy repos', () => {
|
||||
expect(effectiveExternalWorktreeVisibility(makeRepo(), false)).toBe('hide')
|
||||
expect(effectiveExternalWorktreeVisibility(makeRepo(), true)).toBe('show')
|
||||
})
|
||||
|
||||
it('resolves repo override before host default before the compatibility fallback', () => {
|
||||
expect(
|
||||
effectiveExternalWorktreeVisibility(makeRepo({ externalWorktreeVisibility: 'show' }), false, {
|
||||
external: 'hide'
|
||||
})
|
||||
).toBe('show')
|
||||
expect(effectiveExternalWorktreeVisibility(makeRepo(), true, { external: 'hide' })).toBe('hide')
|
||||
expect(effectiveExternalWorktreeVisibility(makeRepo(), true)).toBe('show')
|
||||
})
|
||||
|
||||
it('materializes only implicit legacy visibility when adding the safe global default', () => {
|
||||
const legacy = makeRepo({ externalWorktreeVisibilityLegacy: undefined })
|
||||
const explicit = makeRepo({ id: 'explicit', externalWorktreeVisibility: 'hide' })
|
||||
@@ -393,47 +224,6 @@ describe('external worktree visibility policy', () => {
|
||||
expect(migrated.repos[0]?.externalWorktreeVisibility).toBe('show')
|
||||
})
|
||||
|
||||
it('treats persisted repos without explicit visibility as legacy for upgrade safety', () => {
|
||||
expect(isLegacyRepoForExternalWorktreeVisibility(makeRepo())).toBe(true)
|
||||
})
|
||||
|
||||
it('computes legacy status from rollout timing, not from the stored visibility value', () => {
|
||||
expect(
|
||||
isLegacyRepoForExternalWorktreeVisibility(
|
||||
makeRepo({
|
||||
addedAt: EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT - 1,
|
||||
externalWorktreeVisibility: 'hide'
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('honors an explicit legacy marker after visibility changes', () => {
|
||||
expect(
|
||||
isLegacyRepoForExternalWorktreeVisibility(
|
||||
makeRepo({
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isLegacyRepoForExternalWorktreeVisibility(
|
||||
makeRepo({
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats repos without a valid addedAt as legacy for upgrade safety', () => {
|
||||
expect(
|
||||
isLegacyRepoForExternalWorktreeVisibility(makeRepo({ addedAt: undefined as never }))
|
||||
).toBe(true)
|
||||
expect(isLegacyRepoForExternalWorktreeVisibility(makeRepo({ addedAt: Number.NaN }))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows explicitly imported external worktrees while repo visibility stays hide', () => {
|
||||
const repo = makeRepo({
|
||||
externalWorktreeVisibility: 'hide',
|
||||
@@ -481,40 +271,6 @@ describe('external worktree visibility policy', () => {
|
||||
describe('agent scratch worktrees', () => {
|
||||
const scratchPath = '/repos/app/.claude/worktrees/agent-a04ccaaa55ddadb91'
|
||||
|
||||
it('classifies sub-agent scratch paths as agent-scratch without metadata', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({ path: scratchPath, isMainWorktree: false }),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
).toBe('agent-scratch')
|
||||
})
|
||||
|
||||
it('classifies scratch worktrees created inside another linked checkout', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
const linkedCheckoutPath = '/orca/workspaces/app/feature-x'
|
||||
expect(
|
||||
classifyWorktreeOwnership({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({
|
||||
path: `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`,
|
||||
isMainWorktree: false
|
||||
}),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo),
|
||||
agentScratchWorktreePathMatcher: createAgentScratchWorktreePathMatcher(
|
||||
[repo.path, linkedCheckoutPath],
|
||||
[]
|
||||
)
|
||||
})
|
||||
).toBe('agent-scratch')
|
||||
})
|
||||
|
||||
it('keeps strong Orca metadata authoritative over the scratch path match', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
@@ -546,25 +302,6 @@ describe('agent scratch worktrees', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows agent scratch when its repo policy is enabled', () => {
|
||||
const repo = makeRepo({
|
||||
externalWorktreeVisibility: 'hide',
|
||||
agentWorktreeVisibility: 'show'
|
||||
})
|
||||
const settings = makeSettings()
|
||||
|
||||
for (const path of [scratchPath, '/repos/app/.gsd-workspaces/phase-1']) {
|
||||
const detected = toDetectedWorktree({
|
||||
repo,
|
||||
settings,
|
||||
worktree: makeWorktree({ path, isMainWorktree: false }),
|
||||
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
|
||||
})
|
||||
|
||||
expect(detected).toMatchObject({ ownership: 'agent-scratch', visible: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('applies built-in source preferences independently after legacy migration', () => {
|
||||
const repo = makeRepo({
|
||||
agentWorktreeVisibility: 'show',
|
||||
@@ -615,32 +352,6 @@ describe('agent scratch worktrees', () => {
|
||||
expect(applyMetadataFallbackVisibility(detected).visible).toBe(false)
|
||||
})
|
||||
|
||||
it('still shows agent scratch for the selected checkout or an explicit import', () => {
|
||||
const repo = makeRepo({
|
||||
externalWorktreeVisibility: 'hide',
|
||||
importedExternalWorktreePaths: [scratchPath]
|
||||
})
|
||||
expect(
|
||||
shouldShowWorktree({
|
||||
repo,
|
||||
worktree: makeWorktree({ path: scratchPath, isMainWorktree: false }),
|
||||
ownership: 'agent-scratch',
|
||||
isLegacyRepoForVisibility: false,
|
||||
isSelectedCheckout: false,
|
||||
importedExternalWorktreePaths: repo.importedExternalWorktreePaths
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldShowWorktree({
|
||||
repo: makeRepo(),
|
||||
worktree: makeWorktree({ path: scratchPath, isMainWorktree: false }),
|
||||
ownership: 'agent-scratch',
|
||||
isLegacyRepoForVisibility: false,
|
||||
isSelectedCheckout: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps agent scratch hidden in the metadata fallback while revealing the rest', () => {
|
||||
const repo = makeRepo()
|
||||
const settings = makeSettings()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Repo } from '../repo-types'
|
||||
import {
|
||||
buildDefaultWorktreeSourcePreferenceUpdate,
|
||||
@@ -25,19 +25,6 @@ function repo(overrides: Partial<Repo> = {}): Repo {
|
||||
}
|
||||
|
||||
describe('worktree visibility sources', () => {
|
||||
it('classifies each built-in independently across linked checkouts', () => {
|
||||
const classify = createWorktreeVisibilitySourceMatcher(['/repo', '/worktrees/feature'], [], [])
|
||||
expect(classify('/repo/.claude/worktrees/review')).toEqual({
|
||||
kind: 'built-in',
|
||||
id: 'claude'
|
||||
})
|
||||
expect(classify('/worktrees/feature/.gsd-workspaces/phase-1')).toEqual({
|
||||
kind: 'built-in',
|
||||
id: 'gsd'
|
||||
})
|
||||
expect(classify('/other/.claude/worktrees/review')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches custom descendants with Windows and WSL comparison semantics', () => {
|
||||
const windows = createWorktreeVisibilitySourceMatcher(
|
||||
[],
|
||||
@@ -61,26 +48,6 @@ describe('worktree visibility sources', () => {
|
||||
expect(wsl('//wsl.localhost/Ubuntu/home/Dev/team/feature')).toBeNull()
|
||||
})
|
||||
|
||||
it('yields a built-in root to a worktree base the project configured there', () => {
|
||||
const classify = createWorktreeVisibilitySourceMatcher(
|
||||
['/repo'],
|
||||
[],
|
||||
['/repo/.claude/worktrees']
|
||||
)
|
||||
expect(classify('/repo/.claude/worktrees/review')).toBeNull()
|
||||
expect(classify('/repo/.gsd-workspaces/phase-1')).toEqual({ kind: 'built-in', id: 'gsd' })
|
||||
})
|
||||
|
||||
it('preserves WSL path casing when a configured base supersedes a built-in root', () => {
|
||||
const classify = createWorktreeVisibilitySourceMatcher(
|
||||
['//wsl$/Ubuntu/home/Dev/repo'],
|
||||
[],
|
||||
['//wsl.localhost/Ubuntu/home/Dev/repo/.claude/worktrees']
|
||||
)
|
||||
|
||||
expect(classify('//wsl.localhost/Ubuntu/home/Dev/repo/.claude/worktrees/review')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a built-in root a base merely contains', () => {
|
||||
for (const configuredBase of ['/repo', '/']) {
|
||||
const classify = createWorktreeVisibilitySourceMatcher(['/repo'], [], [configuredBase])
|
||||
@@ -125,37 +92,6 @@ describe('worktree visibility sources', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes each candidate once regardless of checkout count', () => {
|
||||
const classify = createWorktreeVisibilitySourceMatcher(
|
||||
Array.from({ length: 200 }, (_, index) => `/repo/worktree-${index}`),
|
||||
Array.from({ length: 32 }, (_, index) => ({
|
||||
id: `custom-${index}`,
|
||||
rootPath: `/custom/${index}`
|
||||
})),
|
||||
[]
|
||||
)
|
||||
const normalize = vi.spyOn(String.prototype, 'normalize')
|
||||
|
||||
classify('/unmatched/worktree')
|
||||
|
||||
expect(normalize).toHaveBeenCalledTimes(1)
|
||||
normalize.mockRestore()
|
||||
})
|
||||
|
||||
it('normalizes each candidate once when a configured base supersedes a built-in root', () => {
|
||||
const classify = createWorktreeVisibilitySourceMatcher(
|
||||
['/repo'],
|
||||
[],
|
||||
['/repo/.claude/worktrees']
|
||||
)
|
||||
const normalize = vi.spyOn(String.prototype, 'normalize')
|
||||
|
||||
classify('/repo/.claude/worktrees/review')
|
||||
|
||||
expect(normalize).toHaveBeenCalledTimes(1)
|
||||
normalize.mockRestore()
|
||||
})
|
||||
|
||||
it('migrates the optional legacy agent policy lazily for both built-ins', () => {
|
||||
const legacy = repo({ agentWorktreeVisibility: 'show' })
|
||||
expect(effectiveBuiltInWorktreeSourceVisibility(legacy, 'claude')).toBe('show')
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
buildWslInteractiveLoginShellCommand,
|
||||
buildWslLoginShellCommand
|
||||
} from './wsl-login-shell-command'
|
||||
import { quotePosixShell } from './shell-argument-quoting'
|
||||
|
||||
const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000
|
||||
let wslShAvailable: boolean | null = null
|
||||
@@ -32,42 +31,7 @@ function canRunWslSh(): boolean {
|
||||
return wslShAvailable
|
||||
}
|
||||
|
||||
function expectValidShSyntax(command: string): void {
|
||||
try {
|
||||
execFileSync('sh', ['-n'], { input: command, timeout: WSL_TEST_COMMAND_TIMEOUT_MS })
|
||||
return
|
||||
} catch (error) {
|
||||
if (
|
||||
process.platform !== 'win32' ||
|
||||
!(error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (!canRunWslSh()) {
|
||||
return
|
||||
}
|
||||
execFileSync('wsl.exe', ['--exec', 'sh', '-n'], {
|
||||
input: command,
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
describe('wsl login shell command helpers', () => {
|
||||
it('quotes single quotes for POSIX shell arguments', () => {
|
||||
expect(quotePosixShell("a'b")).toBe("'a'\\''b'")
|
||||
})
|
||||
|
||||
it('runs commands through the distro user login shell', () => {
|
||||
const command = buildWslLoginShellCommand("printf 'hello'")
|
||||
|
||||
expect(command).toContain('getent passwd')
|
||||
expect(command).toContain('bash|zsh|ksh|mksh|ash)')
|
||||
expect(command).toContain('exec "$_orca_wsl_shell" -ilc')
|
||||
expect(command).toContain('exec /bin/sh -lc')
|
||||
expect(command).toContain("printf '\\''hello'\\''")
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'resolves env-node launchers from the current login-shell PATH on every run',
|
||||
() => {
|
||||
@@ -123,31 +87,6 @@ describe('wsl login shell command helpers', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps command-scoped environment variables in the quoted payload', () => {
|
||||
const command = buildWslLoginShellCommand('HISTFILE=/tmp/orca-history printf "$HISTFILE"')
|
||||
|
||||
expect(command).toContain('\'HISTFILE=/tmp/orca-history printf "$HISTFILE"\'')
|
||||
expectValidShSyntax(command)
|
||||
}, 30_000)
|
||||
|
||||
it('routes through --exec so wsl.exe cannot preprocess argv', () => {
|
||||
expect(buildWslExecArgs('Ubuntu', ['sh', '-lc', 'printf "$HOME"'])).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-lc',
|
||||
'printf "$HOME"'
|
||||
])
|
||||
// A distro-less target still has to bypass the `--` preprocessor.
|
||||
expect(buildWslExecArgs(undefined, ['sh', '-c', 'true'])).toEqual([
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
'true'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves user command variables across the Windows-to-WSL argv boundary', () => {
|
||||
if (!canRunWslSh()) {
|
||||
return
|
||||
@@ -192,17 +131,6 @@ describe('wsl login shell command helpers', () => {
|
||||
)
|
||||
|
||||
describe('captured login shell', () => {
|
||||
it('returns only the fenced payload, dropping rc banner noise', () => {
|
||||
const captured = buildWslCapturedLoginShellCommand('printf directory', 'nonce1')
|
||||
|
||||
expect(
|
||||
captured.readStdout(
|
||||
'To run a command as administrator (user "root"), use "sudo <command>".\n\n' +
|
||||
'__ORCA_WSL_CAPTURE_BEGIN_nonce1__directory__ORCA_WSL_CAPTURE_END_nonce1__'
|
||||
)
|
||||
).toBe('directory')
|
||||
})
|
||||
|
||||
it.skipIf(!canRunWslSh())(
|
||||
'propagates the payload exit status to the caller',
|
||||
() => {
|
||||
@@ -225,13 +153,6 @@ describe('wsl login shell command helpers', () => {
|
||||
30_000
|
||||
)
|
||||
|
||||
it('emits the status plumbing the payload needs', () => {
|
||||
const captured = buildWslCapturedLoginShellCommand('exit 2', 'nonce1')
|
||||
|
||||
expect(captured.command).toContain('_orca_capture_status=$?')
|
||||
expect(captured.command).toContain('exit $_orca_capture_status')
|
||||
})
|
||||
|
||||
it('keeps payload bytes that themselves contain a fence', () => {
|
||||
// A per-call nonce prevents payloads quoting an older marker from truncating capture.
|
||||
const captured = buildWslCapturedLoginShellCommand('cat -- /f', 'nonce2')
|
||||
@@ -244,12 +165,6 @@ describe('wsl login shell command helpers', () => {
|
||||
).toBe(payload)
|
||||
})
|
||||
|
||||
it('returns null when the fence never appeared', () => {
|
||||
const captured = buildWslCapturedLoginShellCommand('printf hi', 'nonce1')
|
||||
|
||||
expect(captured.readStdout('bash: line 1: command not found\n')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the tail when a payload exits before the closing fence', () => {
|
||||
const captured = buildWslCapturedLoginShellCommand('printf partial; exit 2', 'nonce1')
|
||||
|
||||
@@ -257,57 +172,10 @@ describe('wsl login shell command helpers', () => {
|
||||
'partial'
|
||||
)
|
||||
})
|
||||
|
||||
it('gives each invocation a distinct fence', () => {
|
||||
const first = buildWslCapturedLoginShellCommand('printf hi')
|
||||
const second = buildWslCapturedLoginShellCommand('printf hi')
|
||||
|
||||
expect(first.command).not.toBe(second.command)
|
||||
})
|
||||
|
||||
it.skipIf(!canRunWslSh())(
|
||||
'reads a clean payload back from a real distro login shell',
|
||||
() => {
|
||||
const captured = buildWslCapturedLoginShellCommand('printf directory')
|
||||
const stdout = execFileSync(
|
||||
'wsl.exe',
|
||||
buildWslExecArgs(undefined, ['sh', '-lc', captured.command]),
|
||||
{ encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS }
|
||||
)
|
||||
|
||||
expect(captured.readStdout(stdout)).toBe('directory')
|
||||
},
|
||||
30_000
|
||||
)
|
||||
})
|
||||
|
||||
it('starts an interactive login shell without assuming bash', () => {
|
||||
const command = buildWslInteractiveLoginShellCommand()
|
||||
|
||||
expect(command).toContain('getent passwd')
|
||||
expect(command).toContain('if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then')
|
||||
expect(command).toContain('_orca_shell_ready_root=""')
|
||||
expect(command).toContain('if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then')
|
||||
expect(command).toContain('_orca_wsl_shell_name=$(basename "$_orca_wsl_shell"')
|
||||
expect(command).toContain('bash)')
|
||||
expect(command).toContain('--rcfile "${_orca_shell_ready_root}/bash/rcfile"')
|
||||
expect(command).toContain('zsh)')
|
||||
expect(command).toContain('export ZDOTDIR="${_orca_shell_ready_root}/zsh"')
|
||||
expect(command).toContain('exec "$_orca_wsl_shell" -l')
|
||||
expectValidShSyntax(command)
|
||||
})
|
||||
})
|
||||
|
||||
describe('in-guest wrapper root resolution', () => {
|
||||
// Content-addressed wrappers include a host-chosen hash. Guessing from userData lost shell-ready/OSC 133/preflight for every WSL pane.
|
||||
it('prefers the host-published root over the legacy user-data guess', () => {
|
||||
const script = buildWslInteractiveLoginShellCommand()
|
||||
expect(script).toContain('if [ -n "${ORCA_SHELL_READY_ROOT:-}" ]; then')
|
||||
expect(script).toContain('_orca_shell_ready_root="${ORCA_SHELL_READY_ROOT%/}"')
|
||||
// Retain the userData fallback for older hosts.
|
||||
expect(script).toContain('elif [ -n "${ORCA_USER_DATA_PATH:-}" ]; then')
|
||||
})
|
||||
|
||||
it('resolves the published root ahead of the legacy path under a real shell', () => {
|
||||
const script = buildWslInteractiveLoginShellCommand()
|
||||
const prologue = script.split('_orca_wsl_shell_name=')[0] as string
|
||||
|
||||
@@ -3,31 +3,12 @@ import {
|
||||
foldWslUncPathCaseInsensitiveParts,
|
||||
getWslFilesystemBoundaryDistro,
|
||||
isDrvfsLinuxPath,
|
||||
isWslUncPath,
|
||||
parseWslUncPath,
|
||||
resolveWslRepoWorktreeBasePath,
|
||||
toWindowsWslDrivePath,
|
||||
toWindowsWslPath,
|
||||
toWindowsWslUncPath
|
||||
toWindowsWslPath
|
||||
} from './wsl-paths'
|
||||
|
||||
describe('wsl path helpers', () => {
|
||||
it('parses modern and legacy WSL UNC paths without platform checks', () => {
|
||||
expect(parseWslUncPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toEqual({
|
||||
distro: 'Ubuntu',
|
||||
linuxPath: '/home/jin/repo'
|
||||
})
|
||||
expect(parseWslUncPath('\\\\wsl$\\Debian\\home\\jin')).toEqual({
|
||||
distro: 'Debian',
|
||||
linuxPath: '/home/jin'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects ordinary Windows and POSIX paths', () => {
|
||||
expect(isWslUncPath('C:\\Users\\jin\\repo')).toBe(false)
|
||||
expect(isWslUncPath('/home/jin/repo')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/home/jin/repo', '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'],
|
||||
['/', '\\\\wsl.localhost\\Ubuntu\\'],
|
||||
@@ -53,21 +34,6 @@ describe('wsl path helpers', () => {
|
||||
] as const)('converts the DrvFs path %j without a distro lookup', (linuxPath, expected) => {
|
||||
expect(toWindowsWslDrivePath(linuxPath)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each(['\r', '\n', '\u2028', '\u2029'])(
|
||||
'leaves a DrvFs line ending in %j on the distro UNC view',
|
||||
(terminator) => {
|
||||
expect(toWindowsWslPath(`/mnt/c/Users/jin${terminator}`, 'Ubuntu')).toBe(
|
||||
`\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\jin${terminator}`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps mounted-drive paths on the distro UNC view when requested', () => {
|
||||
expect(toWindowsWslUncPath('/mnt/c/Users/jin', 'Ubuntu')).toBe(
|
||||
'\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\jin'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWslRepoWorktreeBasePath', () => {
|
||||
@@ -110,14 +76,6 @@ describe('resolveWslRepoWorktreeBasePath', () => {
|
||||
expect(resolveWslRepoWorktreeBasePath(WSL_REPO, 'D:\\trees')).toBe('D:\\trees')
|
||||
expect(resolveWslRepoWorktreeBasePath(WSL_REPO, '../worktrees')).toBe('../worktrees')
|
||||
})
|
||||
|
||||
it('never rewrites base paths of non-WSL repos', () => {
|
||||
expect(resolveWslRepoWorktreeBasePath('C:\\src\\repo', '/home/jin/trees')).toBe(
|
||||
'/home/jin/trees'
|
||||
)
|
||||
expect(resolveWslRepoWorktreeBasePath('/srv/repo', '/srv/trees')).toBe('/srv/trees')
|
||||
expect(resolveWslRepoWorktreeBasePath('//server/share/repo', '/srv/trees')).toBe('/srv/trees')
|
||||
})
|
||||
})
|
||||
|
||||
describe('foldWslUncPathCaseInsensitiveParts', () => {
|
||||
@@ -144,41 +102,9 @@ describe('foldWslUncPathCaseInsensitiveParts', () => {
|
||||
'//wsl.localhost/ubuntu/MNT/c/Repo'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for non-WSL paths', () => {
|
||||
expect(foldWslUncPathCaseInsensitiveParts('C:\\Users\\jin')).toBeNull()
|
||||
expect(foldWslUncPathCaseInsensitiveParts('//server/share/x')).toBeNull()
|
||||
expect(foldWslUncPathCaseInsensitiveParts('/home/jin')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWslFilesystemBoundaryDistro', () => {
|
||||
it('names the runtime distro for a Windows drive project running WSL git', () => {
|
||||
expect(
|
||||
getWslFilesystemBoundaryDistro({
|
||||
projectPath: 'C:\\Users\\alice\\orca',
|
||||
wslRuntimeDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
).toBe('Ubuntu-24.04')
|
||||
})
|
||||
|
||||
it('stays silent for a Windows drive project running host git', () => {
|
||||
for (const wslRuntimeDistro of [undefined, null, '']) {
|
||||
expect(
|
||||
getWslFilesystemBoundaryDistro({ projectPath: 'C:/Users/alice/orca', wslRuntimeDistro })
|
||||
).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('stays silent for a project already inside the distro', () => {
|
||||
expect(
|
||||
getWslFilesystemBoundaryDistro({
|
||||
projectPath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\orca',
|
||||
wslRuntimeDistro: 'Ubuntu'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
// UNC drvfs paths cross onto the Windows drive regardless of runtime preference.
|
||||
it('names the path distro for the UNC spelling of a drvfs mount', () => {
|
||||
expect(
|
||||
@@ -187,12 +113,6 @@ describe('getWslFilesystemBoundaryDistro', () => {
|
||||
})
|
||||
).toBe('Ubuntu')
|
||||
})
|
||||
|
||||
it('stays silent for POSIX, relative, and plain UNC share paths', () => {
|
||||
for (const projectPath of ['/home/alice/orca', 'orca', '\\\\fileserver\\share\\orca']) {
|
||||
expect(getWslFilesystemBoundaryDistro({ projectPath, wslRuntimeDistro: 'Ubuntu' })).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDrvfsLinuxPath', () => {
|
||||
|
||||
Reference in New Issue
Block a user