mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/workspace-cleanup-dialog
# Conflicts: # src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx # src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx # src/renderer/src/store/slices/diffComments.test.ts
This commit is contained in:
@@ -378,6 +378,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test Windows PowerShell CLI boundary
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
|
||||
- name: Build package inputs
|
||||
run: pnpm run build:release
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ jobs:
|
||||
'!config/reliability-gates.jsonc',
|
||||
'!config/max-lines-baseline.txt',
|
||||
'!config/vitest.config.ts',
|
||||
'config/build-plugins/**',
|
||||
'native/**',
|
||||
'resources/**',
|
||||
'electron.vite.config.ts',
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
|
||||
## Electron UI Validation
|
||||
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
## Concise/Brief Non-obviosu comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
|
||||
type NormalizedInputOptions = Rollup.NormalizedInputOptions
|
||||
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
|
||||
type OutputBundle = Rollup.OutputBundle
|
||||
type OutputChunk = Rollup.OutputChunk
|
||||
@@ -42,9 +43,34 @@ const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
||||
export const GUARDED_ENTRY_NAMES = [
|
||||
...PLAIN_NODE_ENTRY_NAMES,
|
||||
...WORKER_THREAD_ENTRY_NAMES
|
||||
] as const
|
||||
|
||||
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
|
||||
|
||||
// Why: writeBundle skips any name missing from the bundle, so a renamed or
|
||||
// removed rollup input would silently drop that entry from the guard and let the
|
||||
// regression back in. Pin the lists to the input keys at build start instead.
|
||||
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
|
||||
if (typeof input === 'string' || Array.isArray(input)) {
|
||||
return
|
||||
}
|
||||
const inputNames = new Set(Object.keys(input))
|
||||
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
|
||||
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
|
||||
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
|
||||
`the current entry names — a stale name silently stops guarding that entry.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function collectReachableChunks(
|
||||
entry: OutputChunk,
|
||||
@@ -89,41 +115,125 @@ function assertNoElectronRequire(
|
||||
}
|
||||
}
|
||||
|
||||
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
|
||||
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
|
||||
|
||||
export type SmokeTimings = {
|
||||
timeoutMs: number
|
||||
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
|
||||
// needs an uncatchable follow-up to stay a deadline.
|
||||
killGraceMs: number
|
||||
}
|
||||
|
||||
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
|
||||
|
||||
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
|
||||
// can hold the pipes open long after the child is gone.
|
||||
const SMOKE_STDERR_DRAIN_MS = 250
|
||||
|
||||
type SmokeResult = {
|
||||
status: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
stderr: string
|
||||
error?: Error
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
|
||||
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
|
||||
// build forever. Escalate to SIGKILL instead.
|
||||
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let stderr = ''
|
||||
let timedOut = false
|
||||
let settled = false
|
||||
let forceKillTimer: NodeJS.Timeout | undefined
|
||||
let drainTimer: NodeJS.Timeout | undefined
|
||||
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
const deadlineTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill('SIGTERM')
|
||||
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
|
||||
}, timings.timeoutMs)
|
||||
|
||||
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(deadlineTimer)
|
||||
clearTimeout(forceKillTimer)
|
||||
clearTimeout(drainTimer)
|
||||
resolve({ status, signal, stderr, error, timedOut })
|
||||
}
|
||||
|
||||
child.on('error', (error: Error) => finish(null, null, error))
|
||||
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
|
||||
// cannot outlast the process itself.
|
||||
child.on('close', (status, signal) => finish(status, signal))
|
||||
child.on('exit', (status, signal) => {
|
||||
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Why: proves the whole daemon-entry graph resolves under plain Node (no
|
||||
// unresolved requires). require("electron") does not throw in a dev tree with
|
||||
// node_modules present, so the static scan above — not this smoke — is the
|
||||
// electron regression guard; this only catches gross load failures.
|
||||
function smokeLoadDaemonEntry(outputDir: string): void {
|
||||
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
|
||||
const entryPath = join(outputDir, 'daemon-entry.js')
|
||||
const result = spawnSync(process.execPath, [entryPath], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000
|
||||
})
|
||||
const result = await runDaemonEntry(entryPath, timings)
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
|
||||
`${result.error.message}`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr ?? ''
|
||||
// Almost always means the daemon stopped rejecting an empty argv and started
|
||||
// listening instead.
|
||||
if (result.timedOut) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
|
||||
`empty argv under plain Node, so the smoke killed it.`
|
||||
)
|
||||
}
|
||||
if (result.signal) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr
|
||||
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
|
||||
)
|
||||
}
|
||||
if (!stderr.includes('Usage: daemon-entry')) {
|
||||
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
|
||||
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
|
||||
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
|
||||
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
|
||||
`${result.status}). stderr:\n${stderr}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
export function createPlainNodeEntryGuardPlugin(
|
||||
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
|
||||
): Plugin {
|
||||
let daemonOutputDir: string | undefined
|
||||
|
||||
return {
|
||||
name: 'orca-plain-node-entry-guard',
|
||||
buildStart(options: NormalizedInputOptions) {
|
||||
assertEntryNamesAreRollupInputs(options.input)
|
||||
},
|
||||
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
|
||||
// every rebuild, and the guard only needs to gate produced builds.
|
||||
@@ -159,11 +269,11 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
daemonOutputDir = options.dir
|
||||
}
|
||||
},
|
||||
closeBundle() {
|
||||
async closeBundle() {
|
||||
if (daemonOutputDir) {
|
||||
const outputDir = daemonOutputDir
|
||||
daemonOutputDir = undefined
|
||||
smokeLoadDaemonEntry(outputDir)
|
||||
await smokeLoadDaemonEntry(outputDir, smokeTimings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,12 @@ module.exports = {
|
||||
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
|
||||
// unpacked because they ship .node addons + .dylib/.so files that cannot be
|
||||
// dlopen()'d from inside the asar archive.
|
||||
// Why: the OpenCode SQLite worker entry is also spawned by the scanner
|
||||
// service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into
|
||||
// app.asar. Left packed, that spawn fails closed and every OpenCode session
|
||||
// disappears from Agent Session History in packaged builds only. Worker
|
||||
// entries reached solely from the Electron main process stay packed, since
|
||||
// asar redirects their app.asar paths.
|
||||
asarUnpack: [
|
||||
'out/package.json',
|
||||
'out/cli/**',
|
||||
@@ -183,6 +189,7 @@ module.exports = {
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
[
|
||||
{
|
||||
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
|
||||
"kind": "object-property:title",
|
||||
"text": "Terminal 1",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
|
||||
@@ -232,7 +232,6 @@ inline src/renderer/src/components/settings/AgentsPane.tsx
|
||||
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/settings/SettingsFormControls.tsx
|
||||
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
@@ -245,7 +244,6 @@ inline src/renderer/src/components/stats/usage-overview-model.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
|
||||
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
|
||||
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
|
||||
@@ -268,7 +266,6 @@ inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.test.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useIssueMetadata.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/active-agent-note-send.test.ts
|
||||
inline src/renderer/src/lib/file-type-icons.ts
|
||||
|
||||
@@ -1969,7 +1969,7 @@
|
||||
"invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
|
||||
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.",
|
||||
"commands": [
|
||||
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1"
|
||||
@@ -1989,11 +1989,18 @@
|
||||
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-tab-retirement.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
|
||||
"tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"tests/e2e/terminal-parked-close-retirement.spec.ts",
|
||||
"tests/e2e/agent-descendant-process-kill.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"assertions": [
|
||||
"explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-parked-close-retirement.spec.ts",
|
||||
"assertions": [
|
||||
@@ -2711,11 +2718,13 @@
|
||||
"oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
"tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/main/daemon/terminal-host.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
@@ -2727,6 +2736,13 @@
|
||||
"tests/e2e/live-background-terminal-mount-authority.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"assertions": [
|
||||
"completed and explicitly retired workers retain no provider-resume authority on first workspace activation",
|
||||
"the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts",
|
||||
"assertions": [
|
||||
@@ -7867,16 +7883,24 @@
|
||||
"invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
|
||||
"oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
|
||||
"src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
|
||||
"src/main/runtime/orchestration/worker-transcript-read.test.ts",
|
||||
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts"
|
||||
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts",
|
||||
"tests/e2e/completed-worker-retirement-resume.unit.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"assertions": [
|
||||
"an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
|
||||
"assertions": [
|
||||
|
||||
@@ -7,6 +7,9 @@ import process from 'node:process'
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`.
|
||||
const TEST_SUPPORT_FILE_PATTERN =
|
||||
/[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
|
||||
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
|
||||
@@ -73,13 +76,14 @@ function normalizePath(root, filePath) {
|
||||
return path.relative(root, filePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function isSkippedFile(root, filePath) {
|
||||
export function isSkippedFile(root, filePath) {
|
||||
const relative = normalizePath(root, filePath)
|
||||
if (
|
||||
relative.endsWith('.d.ts') ||
|
||||
relative.includes('.test.') ||
|
||||
relative.includes('.spec.') ||
|
||||
relative.includes('/__tests__/')
|
||||
relative.includes('/__tests__/') ||
|
||||
TEST_SUPPORT_FILE_PATTERN.test(relative)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
|
||||
import { collectLocalizationCandidates, isSkippedFile } from './audit-localization-coverage.mjs'
|
||||
|
||||
const ROOT = process.cwd()
|
||||
|
||||
@@ -42,3 +42,23 @@ describe('localization coverage candidates', () => {
|
||||
expect(reports).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('localization coverage file skipping', () => {
|
||||
function skipped(relativePath) {
|
||||
return isSkippedFile(ROOT, `${ROOT}/${relativePath}`)
|
||||
}
|
||||
|
||||
it('skips test-only modules that sit beside their spec', () => {
|
||||
expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true)
|
||||
expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true)
|
||||
expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true)
|
||||
expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true)
|
||||
expect(skipped('src/renderer/src/components/tab-bar/icon-stub.fixture.tsx')).toBe(true)
|
||||
})
|
||||
|
||||
it('still scans shipped modules whose names merely mention a fixture concept', () => {
|
||||
expect(skipped('src/renderer/src/components/browser-pane/fixture-picker.tsx')).toBe(false)
|
||||
expect(skipped('src/renderer/src/components/settings/fixtures-panel.tsx')).toBe(false)
|
||||
expect(skipped('src/renderer/src/components/browser-pane/BrowserPane.tsx')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, '..', '..')
|
||||
const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
||||
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
|
||||
@@ -171,6 +174,29 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
|
||||
// invisible to it and a packed worker entry fails closed — dropping every
|
||||
// OpenCode session in packaged builds while dev stays green. Three legs must
|
||||
// agree on the filename, so all three are read rather than hardcoded.
|
||||
it('unpacks the OpenCode SQLite worker entry the scanner service forks', async () => {
|
||||
const spawnSource = await readFile(
|
||||
join(SRC_MAIN_DIR, 'ai-vault', 'session-scanner-opencode-sqlite-worker-spawn.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const entryFilename = spawnSource.match(/WORKER_ENTRY_FILENAME = '([^']+)'/)?.[1]
|
||||
|
||||
expect(entryFilename).toBeDefined()
|
||||
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
|
||||
|
||||
// Why: the emitted path comes from the rollup input key under
|
||||
// entryFileNames '[name].js', not from the source filename — renaming the
|
||||
// key alone would leave the other two legs agreeing on a file that no
|
||||
// longer exists.
|
||||
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
|
||||
expect(viteConfig).toContain("entryFileNames: '[name].js'")
|
||||
expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`))
|
||||
})
|
||||
|
||||
it('keeps the worker-thread hang watchdog inside app.asar', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).not.toContain(
|
||||
'out/main/main-thread-hang-watchdog-entry.js'
|
||||
|
||||
@@ -1058,36 +1058,6 @@
|
||||
"auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": {
|
||||
"ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.04d54d50ec": {
|
||||
"ko": "orca · zsh"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": {
|
||||
"ko": "생각 중..."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.3822d8d14b": {
|
||||
"ko": "fix/worktree-picker-truncates"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.47f16ecf34": {
|
||||
"ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": {
|
||||
"ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.6218a9014d": {
|
||||
"ko": "pnpm playwright test"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": {
|
||||
"ko": "dashboard.spec.ts"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.70aa182266": {
|
||||
"ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": {
|
||||
"ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요."
|
||||
},
|
||||
"auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": {
|
||||
"ko": "작업 중"
|
||||
},
|
||||
"auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": {
|
||||
"ko": "마일스톤"
|
||||
},
|
||||
@@ -1502,12 +1472,6 @@
|
||||
"auto.components.new.workspace.ProjectCombobox.search": {
|
||||
"ko": "프로젝트 검색..."
|
||||
},
|
||||
"auto.components.new.workspace.ProjectHostSetupCombobox.empty": {
|
||||
"ko": "이 프로젝트에 준비된 호스트가 없습니다."
|
||||
},
|
||||
"auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": {
|
||||
"ko": "호스트 선택"
|
||||
},
|
||||
"auto.components.notification.sound.options.0acd3d384e": {
|
||||
"ko": "클랙"
|
||||
},
|
||||
@@ -1520,9 +1484,6 @@
|
||||
"auto.components.notification.sound.options.86af8d938c": {
|
||||
"ko": "봉 소리"
|
||||
},
|
||||
"auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": {
|
||||
"ko": "기능 활성화"
|
||||
},
|
||||
"auto.components.onboarding.AgentStep.1eee1c7bd8": {
|
||||
"ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요."
|
||||
},
|
||||
@@ -1538,12 +1499,6 @@
|
||||
"auto.components.onboarding.AgentStep.yoloPermissionsTooltip": {
|
||||
"ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다."
|
||||
},
|
||||
"auto.components.onboarding.FeatureSetupChecklist.01426f3a23": {
|
||||
"ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다."
|
||||
},
|
||||
"auto.components.onboarding.FeatureSetupChecklist.c5292c409d": {
|
||||
"ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다."
|
||||
},
|
||||
"auto.components.onboarding.IntegrationsStep.0b4a7d23ab": {
|
||||
"ko": "로그인 중"
|
||||
},
|
||||
@@ -1556,18 +1511,6 @@
|
||||
"auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": {
|
||||
"ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요."
|
||||
},
|
||||
"auto.components.onboarding.RepoStep.466108ab89": {
|
||||
"ko": "선택한 호스트에 존재하는 경로를 입력하세요."
|
||||
},
|
||||
"auto.components.onboarding.RepoStep.6558d50c69": {
|
||||
"ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요."
|
||||
},
|
||||
"auto.components.onboarding.RepoStep.c33b190ca3": {
|
||||
"ko": "호스트 경로만"
|
||||
},
|
||||
"auto.components.onboarding.RepoStep.cf23006ba7": {
|
||||
"ko": "선택한 호스트"
|
||||
},
|
||||
"auto.components.onboarding.ThemeStep.7ee9234e54": {
|
||||
"ko": "Ghostty 설정을 찾았습니다."
|
||||
},
|
||||
@@ -4976,9 +4919,6 @@
|
||||
"auto.components.ui.color.picker.faa855a582": {
|
||||
"ko": "Hex"
|
||||
},
|
||||
"auto.components.ui.team.multi.combobox.18ec58881e": {
|
||||
"ko": "팀 검색..."
|
||||
},
|
||||
"auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": {
|
||||
"ko": "리뷰 필요"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,10 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
|
||||
import {
|
||||
createPlainNodeEntryGuardPlugin,
|
||||
GUARDED_ENTRY_NAMES
|
||||
} from '../build-plugins/plain-node-entry-guard'
|
||||
|
||||
let outputDir: string | undefined
|
||||
|
||||
@@ -45,16 +48,16 @@ function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
|
||||
)
|
||||
}
|
||||
|
||||
function runCloseBundle(plugin: Plugin): void {
|
||||
async function runCloseBundle(plugin: Plugin): Promise<void> {
|
||||
const hook = plugin.closeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected closeBundle hook')
|
||||
}
|
||||
hook.call({} as never)
|
||||
await hook.call({} as never)
|
||||
}
|
||||
|
||||
describe('plain Node entry guard', () => {
|
||||
it('smoke-loads the daemon after output files are written', () => {
|
||||
it('smoke-loads the daemon after output files are written', async () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
@@ -64,17 +67,17 @@ describe('plain Node entry guard', () => {
|
||||
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
|
||||
)
|
||||
|
||||
expect(() => runCloseBundle(plugin)).not.toThrow()
|
||||
await expect(runCloseBundle(plugin)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('runs the deferred smoke from closeBundle', () => {
|
||||
it('runs the deferred smoke from closeBundle', async () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
runWriteBundle(plugin, dir)
|
||||
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
|
||||
|
||||
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
|
||||
await expect(runCloseBundle(plugin)).rejects.toThrow('failed to load under plain Node')
|
||||
})
|
||||
|
||||
it('rejects Electron imports during the static bundle scan', () => {
|
||||
@@ -84,6 +87,70 @@ describe('plain Node entry guard', () => {
|
||||
'requires electron'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects Electron subpath requires', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron/main")')).toThrow(
|
||||
'requires electron'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails the smoke when the daemon exits zero on an empty argv', async () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
|
||||
runWriteBundle(plugin, dir)
|
||||
writeFileSync(join(dir, 'daemon-entry.js'), 'console.error("Usage: daemon-entry <socket>")\n')
|
||||
|
||||
await expect(runCloseBundle(plugin)).rejects.toThrow('did not reject an empty argv')
|
||||
})
|
||||
|
||||
// daemon-entry installs a SIGTERM handler, so the smoke deadline only holds if
|
||||
// it escalates to SIGKILL — spawnSync's own timeout would block here forever.
|
||||
it('kills a daemon that traps SIGTERM and never exits', async () => {
|
||||
const dir = createOutputDir()
|
||||
const plugin = createPlainNodeEntryGuardPlugin({ timeoutMs: 250, killGraceMs: 250 })
|
||||
|
||||
runWriteBundle(plugin, dir)
|
||||
writeFileSync(
|
||||
join(dir, 'daemon-entry.js'),
|
||||
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\n"
|
||||
)
|
||||
|
||||
await expect(runCloseBundle(plugin)).rejects.toThrow('did not exit within 250ms')
|
||||
}, 10_000)
|
||||
})
|
||||
|
||||
// Why: writeBundle skips names absent from the bundle, so a renamed rollup input
|
||||
// would silently stop guarding that entry instead of failing the build.
|
||||
describe('guarded entry names', () => {
|
||||
function runBuildStart(plugin: Plugin, input: unknown): void {
|
||||
const hook = plugin.buildStart
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected buildStart hook')
|
||||
}
|
||||
hook.call({} as never, { input } as Rollup.NormalizedInputOptions)
|
||||
}
|
||||
|
||||
it('rejects a guarded name that is no longer a rollup input', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const input = Object.fromEntries(
|
||||
GUARDED_ENTRY_NAMES.filter((name) => name !== 'stt-worker').map((name) => [
|
||||
name,
|
||||
`${name}.ts`
|
||||
])
|
||||
)
|
||||
|
||||
expect(() => runBuildStart(plugin, input)).toThrow('"stt-worker"')
|
||||
})
|
||||
|
||||
it('passes when every guarded name is a rollup input', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const input = Object.fromEntries(GUARDED_ENTRY_NAMES.map((name) => [name, `${name}.ts`]))
|
||||
|
||||
expect(() => runBuildStart(plugin, input)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// Why (#11161): Electron's module is not registered on worker threads either —
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants as fsConstants,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
@@ -311,43 +312,37 @@ async function verifyForwarding(scripts, home, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: rewrite from the path embedded in the installed command, not a
|
||||
// reconstructed join(home, ...). That way missing/failing-script cases cannot
|
||||
// silently re-run the real script if the install layout changes.
|
||||
function rewriteLauncherScriptPath(command, nextPath) {
|
||||
const match = /if \[ -f '([^']+)'/.exec(command)
|
||||
if (!match) {
|
||||
throw new Error('Installed launcher command did not reference a quoted script path')
|
||||
}
|
||||
return command.replaceAll(match[1], nextPath)
|
||||
}
|
||||
|
||||
async function verifyInstalledLauncher(home, payload) {
|
||||
const settingsPath = join(home, '.claude', 'settings.json')
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
||||
const command = findStrings(settings).find(
|
||||
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
|
||||
)
|
||||
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
|
||||
if (
|
||||
!command ||
|
||||
!command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') ||
|
||||
!command.includes('] && [ -r ') ||
|
||||
!command.includes('else { command -p cat')
|
||||
) {
|
||||
throw new Error('Electron did not install the guarded Claude launcher')
|
||||
}
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
|
||||
try {
|
||||
const missingPath = join(scratch, 'missing-hook.sh')
|
||||
const missingResult = await runShell(
|
||||
rewriteLauncherScriptPath(command, missingPath),
|
||||
command,
|
||||
payload,
|
||||
withoutOrcaEnvironment({ HOME: home })
|
||||
withoutOrcaEnvironment({ HOME: scratch })
|
||||
)
|
||||
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
|
||||
|
||||
const failingPath = join(scratch, 'failing-hook.sh')
|
||||
const failingPath = join(scratch, '.orca', 'agent-hooks', 'claude-hook.sh')
|
||||
mkdirSync(join(scratch, '.orca', 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
|
||||
chmodSync(failingPath, 0o755)
|
||||
const failingResult = await runShell(
|
||||
rewriteLauncherScriptPath(command, failingPath),
|
||||
command,
|
||||
payload,
|
||||
withoutOrcaEnvironment({ HOME: home })
|
||||
withoutOrcaEnvironment({ HOME: scratch })
|
||||
)
|
||||
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
|
||||
throw new Error('Installed launcher did not preserve a running script failure')
|
||||
|
||||
@@ -27,6 +27,12 @@ const checks = [
|
||||
],
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'Linux snapshot renderer tests',
|
||||
command: 'python3',
|
||||
args: ['native/computer-use-linux/runtime_render_test.py'],
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'native provider argument guardrails',
|
||||
run: verifyNativeArgumentGuardrails,
|
||||
@@ -69,6 +75,19 @@ const checks = [
|
||||
run: verifyWindowsProviderHandshake,
|
||||
enabled: process.platform === 'win32'
|
||||
},
|
||||
{
|
||||
name: 'Windows snapshot renderer tests',
|
||||
command: 'powershell.exe',
|
||||
args: [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
'native/computer-use-windows/runtime-render.test.ps1'
|
||||
],
|
||||
enabled: process.platform === 'win32'
|
||||
},
|
||||
{
|
||||
name: 'macOS helper app bundle and signature',
|
||||
run: verifyMacOSHelperApp,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
@@ -75,6 +76,7 @@
|
||||
"../src/main/openclaude/hook-service.ts",
|
||||
"../src/main/rolling-file-backup.ts",
|
||||
"../src/main/startup/hydrate-shell-path.ts",
|
||||
"../src/main/startup/windows-shell-path-ownership.ts",
|
||||
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
|
||||
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
|
||||
"../src/main/startup/serve-mode-argv.ts",
|
||||
|
||||
+4
-4
@@ -67,9 +67,9 @@ A common point of drift. Use these conventions for any list-style row (worktrees
|
||||
|
||||
- **Idle:** transparent background.
|
||||
- **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`).
|
||||
- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically.
|
||||
- **Keyboard-selected (cmdk highlight):** do **not** rely on flat `bg-accent` alone on light popover/dialog surfaces — `--accent` (#f5f5f5) is nearly identical to `--background` (#fff), so the cursor vanishes. Use the jump-palette recipe in `main.css` (`.jump-palette-item[data-selected='true']`): `color-mix` foreground into background (~12%) plus an inset ring. Expose the mix as `--jump-palette-selection-surface` when nested cutouts (status pips) must match. The `data-selected` attribute is set by `cmdk` automatically.
|
||||
- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, _plus_ a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight.
|
||||
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app.
|
||||
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. Mix from existing tokens (`foreground`/`background`/`accent`) so light/dark stay aligned.
|
||||
|
||||
### Color mixing
|
||||
|
||||
@@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you'
|
||||
|
||||
Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern:
|
||||
|
||||
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
|
||||
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
|
||||
- Use `cn()` for class merging. Pass user `className` last so callers can override.
|
||||
- Use `class-variance-authority` (CVA) for variants when there are multiple.
|
||||
|
||||
@@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork:
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -154,7 +154,7 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
|
||||
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
|
||||
import { TextInputModal } from '../../../../src/components/TextInputModal'
|
||||
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
|
||||
import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor'
|
||||
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
|
||||
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
|
||||
import {
|
||||
CustomKeyModal,
|
||||
@@ -267,7 +267,6 @@ import {
|
||||
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
|
||||
updateTerminalCwdFromStreamEvent
|
||||
} from '../../../../src/session/mobile-session-route-helpers'
|
||||
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
|
||||
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
|
||||
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
|
||||
import {
|
||||
@@ -275,7 +274,7 @@ import {
|
||||
dismissMobileSessionCreateWarningState,
|
||||
reconcileMobileSessionCreateWarningState
|
||||
} from '../../../../src/session/mobile-session-create-warning-state'
|
||||
import { colors, spacing } from '../../../../src/theme/mobile-theme'
|
||||
import { colors } from '../../../../src/theme/mobile-theme'
|
||||
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
|
||||
import { styles } from '../../../../src/session/mobile-session-styles'
|
||||
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
|
||||
@@ -302,132 +301,6 @@ import type {
|
||||
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
|
||||
function MarkdownReader({
|
||||
documentId,
|
||||
doc,
|
||||
onRefresh,
|
||||
onChange,
|
||||
onSave,
|
||||
onCopy,
|
||||
onDiscard,
|
||||
keyboardLift
|
||||
}: {
|
||||
documentId: string
|
||||
doc: MarkdownDocState | undefined
|
||||
onRefresh: () => void
|
||||
onChange: (content: string) => void
|
||||
onSave: () => void
|
||||
onCopy: () => void
|
||||
onDiscard: () => void
|
||||
keyboardLift: number
|
||||
}) {
|
||||
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
|
||||
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
|
||||
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
|
||||
if (!doc || doc.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (doc.status === 'error') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<Text style={styles.markdownError}>{doc.message}</Text>
|
||||
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
|
||||
<RefreshCw size={14} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownRefreshText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const statusText = doc.saveError
|
||||
? doc.saveError
|
||||
: doc.readOnlyReason
|
||||
? 'Read only'
|
||||
: doc.stale
|
||||
? 'Changed on desktop'
|
||||
: null
|
||||
const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
|
||||
const showCopy = doc.saveError || !doc.editable
|
||||
const showSave = doc.isDirty || doc.saving
|
||||
const showFloatingActions = statusText || showRefresh || showCopy || showSave
|
||||
|
||||
return (
|
||||
<View style={styles.markdownEditor}>
|
||||
<MobileRichMarkdownEditor
|
||||
key={documentId}
|
||||
content={doc.localContent}
|
||||
editable={doc.editable && !doc.saving}
|
||||
onChange={onChange}
|
||||
onKeyboardInsetChange={setWebviewKeyboardInset}
|
||||
/>
|
||||
{showFloatingActions ? (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.markdownFloatingBar,
|
||||
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
|
||||
{
|
||||
bottom: resolveMarkdownFloatingActionsBottom({
|
||||
keyboardLift: effectiveKeyboardLift,
|
||||
restingBottom: spacing.lg,
|
||||
liftedClearance: spacing.md
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
{statusText ? (
|
||||
<Text
|
||||
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{statusText}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.markdownFloatingActions}>
|
||||
{showCopy ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showRefresh ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
|
||||
<RefreshCw size={13} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{doc.isDirty ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showSave ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.markdownFloatingButton,
|
||||
styles.markdownSaveButton,
|
||||
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
|
||||
]}
|
||||
disabled={!doc.editable || !doc.isDirty || doc.saving}
|
||||
onPress={onSave}
|
||||
>
|
||||
{doc.saving ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Text style={styles.markdownFloatingButtonText}>Save</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffLineRow({
|
||||
line,
|
||||
title,
|
||||
@@ -4636,7 +4509,7 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
) : activeMarkdownTab ? (
|
||||
<View style={styles.markdownFrame}>
|
||||
<MarkdownReader
|
||||
<MobileMarkdownReader
|
||||
documentId={activeMarkdownTab.id}
|
||||
doc={markdownDocs.get(activeMarkdownTab.id)}
|
||||
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
|
||||
|
||||
@@ -115,13 +115,15 @@ import {
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { triggerMediumImpact } from '../../../src/platform/haptics'
|
||||
import {
|
||||
type GitHubProjectSortDirection,
|
||||
type GitHubProjectTable as SharedGitHubProjectTable,
|
||||
groupRows,
|
||||
isIterationCurrent,
|
||||
sortRows,
|
||||
type ProjectGroup
|
||||
} from '../../../src/tasks/mobile-github-project-group-sort'
|
||||
} from '../../../../src/shared/github-project-group-sort'
|
||||
import type {
|
||||
GitHubProjectSortDirection,
|
||||
GitHubProjectTable as SharedGitHubProjectTable
|
||||
} from '../../../../src/shared/github-project-types'
|
||||
import {
|
||||
CROSS_REPO_DISPLAY_LIMIT,
|
||||
isGitHubWorkItemsSshRemoteRequiredError,
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import { Check, ChevronDown } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { searchBaseRefs } from '../source-control/mobile-base-ref-search'
|
||||
|
||||
type Props = {
|
||||
client: RpcClient | null
|
||||
worktreeId: string
|
||||
value: string
|
||||
onChange: (ref: string) => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
// Base-branch field for the create-PR composer: a free-text input that also searches
|
||||
// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's
|
||||
// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still
|
||||
// be entered.
|
||||
export function MobilePrBasePicker({
|
||||
client,
|
||||
worktreeId,
|
||||
value,
|
||||
onChange,
|
||||
editable = true
|
||||
}: Props) {
|
||||
const [results, setResults] = useState<string[]>([])
|
||||
const [focused, setFocused] = useState(false)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Guards: drop results after unmount, and ignore an earlier search whose response
|
||||
// arrives after a later one (out-of-order network) so stale matches can't clobber.
|
||||
const mounted = useRef(true)
|
||||
const seq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mounted.current = false
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const queryRefs = useCallback(
|
||||
(query: string) => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
}
|
||||
if (!client || query.trim().length === 0) {
|
||||
// Advance the generation so an earlier in-flight search can't land and
|
||||
// repopulate results after the input was cleared.
|
||||
seq.current += 1
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(() => {
|
||||
const requestSeq = ++seq.current
|
||||
void searchBaseRefs(client, worktreeId, query.trim())
|
||||
.then((refs) => {
|
||||
if (!mounted.current || requestSeq !== seq.current) {
|
||||
return
|
||||
}
|
||||
setResults(refs.filter((r) => r !== query).slice(0, 6))
|
||||
})
|
||||
// Why: a rejected ref search must not escape as an unhandled rejection;
|
||||
// drop to an empty result set (free text stays valid to submit).
|
||||
.catch(() => {
|
||||
if (mounted.current && requestSeq === seq.current) {
|
||||
setResults([])
|
||||
}
|
||||
})
|
||||
}, 200)
|
||||
},
|
||||
[client, worktreeId]
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[styles.inputShell, !editable && styles.inputShellDisabled]}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={(text) => {
|
||||
onChange(text)
|
||||
queryRefs(text)
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder="main"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={editable}
|
||||
/>
|
||||
<ChevronDown size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</View>
|
||||
{focused && results.length > 0 ? (
|
||||
<View style={styles.results}>
|
||||
{results.map((ref) => (
|
||||
<Pressable
|
||||
key={ref}
|
||||
style={({ pressed }) => [styles.resultRow, pressed && styles.resultRowPressed]}
|
||||
onPress={() => {
|
||||
onChange(ref)
|
||||
setResults([])
|
||||
}}
|
||||
>
|
||||
<Text style={styles.resultText} numberOfLines={1}>
|
||||
{ref}
|
||||
</Text>
|
||||
{ref === value ? (
|
||||
<Check size={14} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputShell: {
|
||||
minHeight: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
inputShellDisabled: {
|
||||
opacity: 0.6
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
results: {
|
||||
marginTop: spacing.xs,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.input,
|
||||
backgroundColor: colors.bgPanel,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
resultRow: {
|
||||
minHeight: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
resultRowPressed: { backgroundColor: colors.bgRaised },
|
||||
resultText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontFamily: typography.monoFamily
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createElement, createRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MobileRichMarkdownEditor,
|
||||
type MobileRichMarkdownEditorHandle
|
||||
} from './MobileRichMarkdownEditor'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dismissKeyboard: vi.fn(),
|
||||
injectJavaScript: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
Keyboard: { dismiss: mocks.dismissKeyboard },
|
||||
Linking: { openURL: vi.fn() },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('ScrollView', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
View: 'View'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-native-webview', () => {
|
||||
const WebView = forwardRef((props: Record<string, unknown>, ref) => {
|
||||
useImperativeHandle(ref, () => ({ injectJavaScript: mocks.injectJavaScript }))
|
||||
return createElement('WebView', props)
|
||||
})
|
||||
return { WebView, default: WebView }
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Bold: 'Bold',
|
||||
ChevronDown: 'ChevronDown',
|
||||
Code2: 'Code2',
|
||||
FileCode2: 'FileCode2',
|
||||
Heading1: 'Heading1',
|
||||
Heading2: 'Heading2',
|
||||
Heading3: 'Heading3',
|
||||
ImageIcon: 'ImageIcon',
|
||||
Italic: 'Italic',
|
||||
Keyboard: 'Keyboard',
|
||||
Link: 'Link',
|
||||
List: 'List',
|
||||
ListOrdered: 'ListOrdered',
|
||||
ListTodo: 'ListTodo',
|
||||
Pilcrow: 'Pilcrow',
|
||||
Quote: 'Quote',
|
||||
Strikethrough: 'Strikethrough'
|
||||
}))
|
||||
|
||||
describe('MobileRichMarkdownEditor', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('exposes WebView keyboard dismissal to its native parent', () => {
|
||||
const editorRef = createRef<MobileRichMarkdownEditorHandle>()
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileRichMarkdownEditor, {
|
||||
ref: editorRef,
|
||||
content: '',
|
||||
editable: true,
|
||||
onChange: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
act(() => editorRef.current?.dismissKeyboard())
|
||||
|
||||
expect(mocks.injectJavaScript).toHaveBeenCalledWith(
|
||||
expect.stringContaining('window.__orcaRichMarkdown.dismissKeyboard()')
|
||||
)
|
||||
expect(mocks.dismissKeyboard).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,15 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react'
|
||||
import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ComponentType,
|
||||
type ForwardedRef
|
||||
} from 'react'
|
||||
import { Keyboard, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
Bold,
|
||||
Code2,
|
||||
@@ -77,6 +87,10 @@ type Props = {
|
||||
onKeyboardInsetChange?: (bottom: number) => void
|
||||
}
|
||||
|
||||
export type MobileRichMarkdownEditorHandle = {
|
||||
dismissKeyboard: () => void
|
||||
}
|
||||
|
||||
type EditorWebViewMessage =
|
||||
| { type: 'ready' }
|
||||
| { type: 'change'; markdown: string; generation: number }
|
||||
@@ -107,12 +121,10 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [
|
||||
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
|
||||
]
|
||||
|
||||
function MobileRichMarkdownEditorInner({
|
||||
content,
|
||||
editable,
|
||||
onChange,
|
||||
onKeyboardInsetChange
|
||||
}: Props) {
|
||||
function MobileRichMarkdownEditorInner(
|
||||
{ content, editable, onChange, onKeyboardInsetChange }: Props,
|
||||
ref: ForwardedRef<MobileRichMarkdownEditorHandle>
|
||||
) {
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
const readyRef = useRef(false)
|
||||
const documentGenerationRef = useRef(0)
|
||||
@@ -227,6 +239,15 @@ function MobileRichMarkdownEditorInner({
|
||||
[inject]
|
||||
)
|
||||
|
||||
const dismissKeyboard = useCallback(() => {
|
||||
// Why: the caret lives in the WebView, so the injected blur is what closes the keyboard;
|
||||
// Keyboard.dismiss only clears a native TextInput that stole focus first.
|
||||
inject('window.__orcaRichMarkdown && window.__orcaRichMarkdown.dismissKeyboard();')
|
||||
Keyboard.dismiss()
|
||||
}, [inject])
|
||||
|
||||
useImperativeHandle(ref, () => ({ dismissKeyboard }), [dismissKeyboard])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.toolbar}>
|
||||
@@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({
|
||||
)
|
||||
}
|
||||
|
||||
export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner)
|
||||
export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner))
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
||||
@@ -149,8 +149,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
// Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious
|
||||
// control, matching TextInputModal / MobilePrBasePicker input shells.
|
||||
// Keep search visually distinct from surrounding panels.
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
|
||||
@@ -11,9 +11,7 @@ function editorScript(): string {
|
||||
return script ?? ''
|
||||
}
|
||||
|
||||
function extractFunctionSource(script: string, name: string): string {
|
||||
const start = script.indexOf(`function ${name}`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
function extractBracedSource(script: string, start: number, label: string): string {
|
||||
const bodyStart = script.indexOf('{', start)
|
||||
let depth = 0
|
||||
for (let index = bodyStart; index < script.length; index += 1) {
|
||||
@@ -28,7 +26,19 @@ function extractFunctionSource(script: string, name: string): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not extract ${name}`)
|
||||
throw new Error(`Could not extract ${label}`)
|
||||
}
|
||||
|
||||
function extractFunctionSource(script: string, name: string): string {
|
||||
const start = script.indexOf(`function ${name}`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
return extractBracedSource(script, start, name)
|
||||
}
|
||||
|
||||
function extractEditorListenerSource(script: string, type: string): string {
|
||||
const start = script.indexOf(`editor.addEventListener('${type}'`)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
return `${extractBracedSource(script, start, `${type} listener`)});`
|
||||
}
|
||||
|
||||
function runtimeMarkdownToHtml(markdown: string, editable: boolean): string {
|
||||
@@ -65,6 +75,134 @@ function runtimeListMarkdown(): (list: unknown) => string {
|
||||
return new Function(sources)() as (list: unknown) => string
|
||||
}
|
||||
|
||||
type FakeRange = {
|
||||
commonAncestorContainer: unknown
|
||||
cloneRange: () => FakeRange
|
||||
selectNodeContents: (node: unknown) => void
|
||||
collapse: (toStart: boolean) => void
|
||||
}
|
||||
|
||||
function createFakeRange(container: unknown): FakeRange {
|
||||
const range: FakeRange = {
|
||||
commonAncestorContainer: container,
|
||||
cloneRange: () => createFakeRange(range.commonAncestorContainer),
|
||||
selectNodeContents: (node) => {
|
||||
range.commonAncestorContainer = node
|
||||
},
|
||||
collapse: () => {}
|
||||
}
|
||||
return range
|
||||
}
|
||||
|
||||
type FakeClickEvent = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
target: { closest: (selector: string) => unknown }
|
||||
preventDefault: () => void
|
||||
}
|
||||
|
||||
// Drives the editor's real selection and click handling against a stub DOM whose blur
|
||||
// drops the selection, the way WebKit does.
|
||||
function createSelectionRuntime(caretContainer: string | null) {
|
||||
const liveNodes = new Set(caretContainer ? [caretContainer] : [])
|
||||
const listeners = new Map<string, (event: FakeClickEvent) => void>()
|
||||
let focused = caretContainer != null
|
||||
let ranges: FakeRange[] = caretContainer ? [createFakeRange(caretContainer)] : []
|
||||
let caretAtPoint: string | null = null
|
||||
|
||||
const editor = {
|
||||
contains: (node: unknown) => typeof node === 'string' && liveNodes.has(node),
|
||||
focus: () => {
|
||||
focused = true
|
||||
fakeDocument.activeElement = editor
|
||||
},
|
||||
blur: () => {
|
||||
focused = false
|
||||
fakeDocument.activeElement = null
|
||||
ranges = []
|
||||
},
|
||||
addEventListener: (type: string, handler: (event: FakeClickEvent) => void) => {
|
||||
listeners.set(type, handler)
|
||||
}
|
||||
}
|
||||
const fakeDocument: {
|
||||
activeElement: unknown
|
||||
createRange: () => FakeRange
|
||||
caretRangeFromPoint: () => FakeRange | null
|
||||
} = {
|
||||
activeElement: focused ? editor : null,
|
||||
createRange: () => createFakeRange('detached'),
|
||||
caretRangeFromPoint: () => (caretAtPoint ? createFakeRange(caretAtPoint) : null)
|
||||
}
|
||||
const fakeWindow = {
|
||||
getSelection: () => ({
|
||||
get rangeCount() {
|
||||
return ranges.length
|
||||
},
|
||||
getRangeAt: (index: number) => ranges[index],
|
||||
removeAllRanges: () => {
|
||||
ranges = []
|
||||
},
|
||||
addRange: (range: FakeRange) => {
|
||||
ranges = [range]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const script = editorScript()
|
||||
const sources = [
|
||||
'var editor = arguments[0];',
|
||||
'var document = arguments[1];',
|
||||
'var window = arguments[2];',
|
||||
'var editable = true;',
|
||||
'var savedSelectionRange = null;',
|
||||
'var selectionDroppedOnBlur = false;',
|
||||
'function post() {}',
|
||||
'function emitChange() {}',
|
||||
extractFunctionSource(script, 'focusEditor'),
|
||||
extractFunctionSource(script, 'rememberSelection'),
|
||||
extractFunctionSource(script, 'applySelectionRange'),
|
||||
extractFunctionSource(script, 'caretRangeAtPoint'),
|
||||
extractFunctionSource(script, 'dismissKeyboard'),
|
||||
extractFunctionSource(script, 'restoreSelectionOrEnd'),
|
||||
extractEditorListenerSource(script, 'click'),
|
||||
'return { dismissKeyboard: dismissKeyboard, restoreSelectionOrEnd: restoreSelectionOrEnd };'
|
||||
].join('\n')
|
||||
const api = new Function(sources)(editor, fakeDocument, fakeWindow) as {
|
||||
dismissKeyboard: () => void
|
||||
restoreSelectionOrEnd: () => void
|
||||
}
|
||||
|
||||
return {
|
||||
dismissKeyboard: api.dismissKeyboard,
|
||||
restoreSelectionOrEnd: api.restoreSelectionOrEnd,
|
||||
tapAt: (container: string, options?: { uneditableAncestor?: string }) => {
|
||||
liveNodes.add(container)
|
||||
caretAtPoint = container
|
||||
listeners.get('click')?.({
|
||||
clientX: 12,
|
||||
clientY: 34,
|
||||
target: {
|
||||
closest: (selector: string) =>
|
||||
selector === '[contenteditable="false"]' ? (options?.uneditableAncestor ?? null) : null
|
||||
},
|
||||
preventDefault: () => {}
|
||||
})
|
||||
},
|
||||
detachEditorContent: () => liveNodes.clear(),
|
||||
selectedContainer: () => {
|
||||
if (ranges.length === 0) {
|
||||
return null
|
||||
}
|
||||
const container = ranges[0].commonAncestorContainer
|
||||
return container === editor ? 'editor-end' : (container as string)
|
||||
},
|
||||
get focused() {
|
||||
return focused
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile rich markdown editor HTML', () => {
|
||||
it('builds parseable WebView JavaScript', () => {
|
||||
const script = editorScript()
|
||||
@@ -233,4 +371,64 @@ describe('mobile rich markdown editor HTML', () => {
|
||||
expect(emitChange).not.toContain('window.setTimeout')
|
||||
expect(emitChange).toContain('generation: pendingGeneration')
|
||||
})
|
||||
|
||||
it('exposes a keyboard dismissal command that blurs WebView focus', () => {
|
||||
const script = editorScript()
|
||||
const dismissKeyboard = extractFunctionSource(script, 'dismissKeyboard')
|
||||
|
||||
expect(dismissKeyboard).toContain('rememberSelection()')
|
||||
expect(dismissKeyboard).toContain('document.activeElement.blur()')
|
||||
expect(dismissKeyboard).toContain('editor.blur()')
|
||||
expect(script).toContain('dismissKeyboard: dismissKeyboard')
|
||||
})
|
||||
|
||||
it('reclaims editor focus at the tapped caret after keyboard dismissal', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.tapAt('paragraph-7')
|
||||
|
||||
expect(runtime.focused).toBe(true)
|
||||
expect(runtime.selectedContainer()).toBe('paragraph-7')
|
||||
})
|
||||
|
||||
it('leaves task-list label taps to the checkbox instead of refocusing the editor', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.tapAt('paragraph-7', { uneditableAncestor: 'task-label' })
|
||||
|
||||
expect(runtime.focused).toBe(false)
|
||||
expect(runtime.selectedContainer()).toBe(null)
|
||||
})
|
||||
|
||||
it('restores the pre-dismissal caret so commands do not insert at the document end', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
expect(runtime.selectedContainer()).toBe(null)
|
||||
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('paragraph-3')
|
||||
expect(runtime.focused).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the document end when no caret was ever placed', () => {
|
||||
const runtime = createSelectionRuntime(null)
|
||||
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
|
||||
it('drops a remembered caret whose nodes left the document', () => {
|
||||
const runtime = createSelectionRuntime('paragraph-3')
|
||||
|
||||
runtime.dismissKeyboard()
|
||||
runtime.detachEditorContent()
|
||||
runtime.restoreSelectionOrEnd()
|
||||
|
||||
expect(runtime.selectedContainer()).toBe('editor-end')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
|
||||
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
|
||||
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
|
||||
|
||||
export function escapeInjectedJavaScriptString(value: string): string {
|
||||
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
|
||||
@@ -559,6 +561,9 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
window.clearTimeout(inputTimer);
|
||||
documentGeneration = Number(generation) || 0;
|
||||
suppressInput = true;
|
||||
// Why: replacing innerHTML detaches the remembered caret's nodes.
|
||||
savedSelectionRange = null;
|
||||
selectionDroppedOnBlur = false;
|
||||
lastMarkdown = String(markdown || '');
|
||||
editor.innerHTML = markdownToHtml(lastMarkdown);
|
||||
syncTaskCheckboxesDisabled();
|
||||
@@ -571,39 +576,8 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
syncTaskCheckboxesDisabled();
|
||||
}
|
||||
|
||||
function focusEditor() {
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function restoreSelectionOrEnd() {
|
||||
focusEditor();
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount > 0) return;
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function wrapSelection(tagName) {
|
||||
restoreSelectionOrEnd();
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
var range = selection.getRangeAt(0);
|
||||
if (range.collapsed) return;
|
||||
var wrapper = document.createElement(tagName);
|
||||
try {
|
||||
range.surroundContents(wrapper);
|
||||
} catch (_error) {
|
||||
wrapper.appendChild(range.extractContents());
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.selectAllChildren(wrapper);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
|
||||
function runCommand(command) {
|
||||
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
|
||||
restoreSelectionOrEnd();
|
||||
@@ -632,6 +606,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
}
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
selectionDroppedOnBlur = false;
|
||||
if (editable) emitChange();
|
||||
});
|
||||
editor.addEventListener('change', function (event) {
|
||||
@@ -654,7 +629,19 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
return;
|
||||
}
|
||||
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
|
||||
if (!input) return;
|
||||
if (!input) {
|
||||
if (!editable) return;
|
||||
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
|
||||
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
|
||||
if (uneditable && uneditable !== editor) return;
|
||||
selectionDroppedOnBlur = false;
|
||||
if (document.activeElement === editor) return;
|
||||
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
|
||||
var caret = caretRangeAtPoint(event.clientX, event.clientY);
|
||||
focusEditor();
|
||||
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
|
||||
return;
|
||||
}
|
||||
if (!editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -670,7 +657,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
|
||||
}
|
||||
});
|
||||
|
||||
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown };
|
||||
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
|
||||
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
|
||||
post({ type: 'ready' });
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose
|
||||
// rememberSelection/selectionDroppedOnBlur this depends on.
|
||||
export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = `
|
||||
function dismissKeyboard() {
|
||||
// Why: WebKit discards the DOM selection on blur, so capture the caret before it goes.
|
||||
rememberSelection();
|
||||
selectionDroppedOnBlur = true;
|
||||
if (document.activeElement && document.activeElement.blur) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
editor.blur();
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,71 @@
|
||||
// Caret and selection management for the WebView editor. Split out so the editor
|
||||
// document script stays inside its line budget.
|
||||
export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = `
|
||||
var savedSelectionRange = null;
|
||||
var selectionDroppedOnBlur = false;
|
||||
|
||||
function focusEditor() {
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function rememberSelection() {
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
var range = selection.getRangeAt(0);
|
||||
if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange();
|
||||
}
|
||||
|
||||
function applySelectionRange(range) {
|
||||
var selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
savedSelectionRange = range.cloneRange();
|
||||
}
|
||||
|
||||
function caretRangeAtPoint(x, y) {
|
||||
if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
|
||||
if (!document.caretPositionFromPoint) return null;
|
||||
var position = document.caretPositionFromPoint(x, y);
|
||||
if (!position) return null;
|
||||
var range = document.createRange();
|
||||
range.setStart(position.offsetNode, position.offset);
|
||||
range.collapse(true);
|
||||
return range;
|
||||
}
|
||||
|
||||
function restoreSelectionOrEnd() {
|
||||
focusEditor();
|
||||
var selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
// Why: the blur dropped the live selection, so commands would otherwise insert at the document end.
|
||||
if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) {
|
||||
selectionDroppedOnBlur = false;
|
||||
applySelectionRange(savedSelectionRange);
|
||||
return;
|
||||
}
|
||||
if (selection.rangeCount > 0) return;
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
applySelectionRange(range);
|
||||
}
|
||||
|
||||
function wrapSelection(tagName) {
|
||||
restoreSelectionOrEnd();
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
var range = selection.getRangeAt(0);
|
||||
if (range.collapsed) return;
|
||||
var wrapper = document.createElement(tagName);
|
||||
try {
|
||||
range.surroundContents(wrapper);
|
||||
} catch (_error) {
|
||||
wrapper.appendChild(range.extractContents());
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.selectAllChildren(wrapper);
|
||||
emitChange();
|
||||
}
|
||||
`
|
||||
@@ -1,289 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native'
|
||||
import {
|
||||
ArrowRight,
|
||||
GitMerge,
|
||||
GitPullRequestArrow,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../../transport/types'
|
||||
import { triggerError, triggerSuccess } from '../../platform/haptics'
|
||||
import {
|
||||
createMobilePr,
|
||||
getMobilePrCreateSuccessWarning,
|
||||
shouldPushBeforeMobilePrCreate,
|
||||
type MobilePrPrefill
|
||||
} from '../../source-control/mobile-pr-create'
|
||||
import { hostedReviewCopy } from '../../source-control/hosted-review-copy'
|
||||
import {
|
||||
getPrComposeDisabledReason,
|
||||
isBaseHeadDistinct
|
||||
} from '../../source-control/pr-compose-validation'
|
||||
import { MobilePrBasePicker } from '../MobilePrBasePicker'
|
||||
import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles'
|
||||
|
||||
export type PrComposePrefill = MobilePrPrefill
|
||||
|
||||
type Props = {
|
||||
client: RpcClient | null
|
||||
worktreeId: string
|
||||
prefill: PrComposePrefill
|
||||
// Head branch — enables the base≠head guard and the "from <branch>" hint.
|
||||
head?: string | null
|
||||
onCancel: () => void
|
||||
onCreated: (url: string, warning?: string) => void
|
||||
}
|
||||
|
||||
// A plain View keeps the compose form usable inside the PR sidebar ScrollView.
|
||||
export function MobilePrComposeForm({
|
||||
client,
|
||||
worktreeId,
|
||||
prefill,
|
||||
head,
|
||||
onCancel,
|
||||
onCreated
|
||||
}: Props) {
|
||||
const copy = hostedReviewCopy(prefill.provider)
|
||||
const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow
|
||||
const [title, setTitle] = useState(prefill.title)
|
||||
const [body, setBody] = useState(prefill.body)
|
||||
const [base, setBase] = useState(prefill.base)
|
||||
const [draft, setDraft] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill)
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (!client || generating) {
|
||||
return
|
||||
}
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await client.sendRequest('git.generatePullRequestFields', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
base,
|
||||
title,
|
||||
body,
|
||||
draft
|
||||
})
|
||||
if (!response.ok) {
|
||||
setError(response.error?.message || 'Failed to generate PR fields')
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as {
|
||||
success?: boolean
|
||||
fields?: { base: string; title: string; body: string; draft: boolean }
|
||||
error?: string
|
||||
}
|
||||
if (result.success && result.fields) {
|
||||
setBase(result.fields.base || base)
|
||||
setTitle(result.fields.title || title)
|
||||
setBody(result.fields.body || body)
|
||||
setDraft(result.fields.draft)
|
||||
} else if (result.error) {
|
||||
setError(result.error)
|
||||
}
|
||||
} catch (err) {
|
||||
triggerError()
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate PR fields')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [base, body, client, draft, generating, title, worktreeId])
|
||||
|
||||
const headRef = head ?? ''
|
||||
const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef)
|
||||
const submitDisabledReason = getPrComposeDisabledReason({
|
||||
title,
|
||||
base,
|
||||
head: headRef,
|
||||
generating,
|
||||
reviewLabel: copy.reviewLabel
|
||||
})
|
||||
const canSubmit = submitDisabledReason === null
|
||||
const fieldsLocked = submitting || generating
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!client || submitting || !canSubmit) {
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const outcome = await createMobilePr(client, worktreeId, {
|
||||
provider: prefill.provider,
|
||||
base,
|
||||
// Send the same head the submit guard validated against, so the PR opens
|
||||
// from the validated branch instead of a host-inferred one.
|
||||
...(head ? { head } : {}),
|
||||
title,
|
||||
body,
|
||||
draft,
|
||||
pushBeforeCreate
|
||||
})
|
||||
if (outcome.ok) {
|
||||
triggerSuccess()
|
||||
const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider)
|
||||
if (warning) {
|
||||
setError(warning)
|
||||
}
|
||||
onCreated(outcome.url, warning)
|
||||
} else {
|
||||
triggerError()
|
||||
setError(outcome.error)
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
base,
|
||||
body,
|
||||
canSubmit,
|
||||
client,
|
||||
copy.titleLabel,
|
||||
draft,
|
||||
head,
|
||||
onCreated,
|
||||
prefill.provider,
|
||||
pushBeforeCreate,
|
||||
submitting,
|
||||
title,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.headingRow}>
|
||||
<View style={styles.headingTitle}>
|
||||
<ReviewIcon size={14} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text style={styles.heading}>New {copy.reviewLabel}</Text>
|
||||
</View>
|
||||
<View style={styles.headingActions}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.genButton, pressed && styles.genButtonPressed]}
|
||||
disabled={generating || submitting}
|
||||
onPress={() => void generate()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`}
|
||||
>
|
||||
{generating ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
)}
|
||||
<Text style={styles.genButtonText}>{generating ? 'Generating…' : 'Generate'}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={onCancel}
|
||||
disabled={submitting}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
hitSlop={8}
|
||||
>
|
||||
<X size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{head ? (
|
||||
<View style={styles.branchFlow}>
|
||||
<Text style={styles.branchToken} numberOfLines={1}>
|
||||
{head}
|
||||
</Text>
|
||||
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
<Text
|
||||
style={[styles.branchToken, baseConflict && styles.branchTokenError]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{base || 'base'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.fieldStack}>
|
||||
<TextInput
|
||||
style={styles.titleInput}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Title"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
editable={!fieldsLocked}
|
||||
accessibilityLabel={`${copy.titleLabel} title`}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.bodyInput}
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
placeholder="Description (optional)"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
editable={!fieldsLocked}
|
||||
accessibilityLabel={`${copy.titleLabel} description`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{generating ? (
|
||||
<View style={styles.notice}>
|
||||
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
<Text style={styles.noticeText}>Generating title and description…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.baseRow}>
|
||||
<Text style={styles.baseLabel}>Base</Text>
|
||||
<View style={styles.baseControl}>
|
||||
<MobilePrBasePicker
|
||||
client={client}
|
||||
worktreeId={worktreeId}
|
||||
value={base}
|
||||
onChange={setBase}
|
||||
editable={!fieldsLocked}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.draftRow}>
|
||||
<Text style={styles.draftText}>Create as draft</Text>
|
||||
<Switch value={draft} onValueChange={setDraft} disabled={fieldsLocked} />
|
||||
</View>
|
||||
{error || submitDisabledReason ? (
|
||||
<View style={styles.notice}>
|
||||
<TriangleAlert size={13} color={colors.statusRed} strokeWidth={2.1} />
|
||||
<Text style={[styles.noticeText, styles.errorText]}>{error ?? submitDisabledReason}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.submit,
|
||||
(submitting || !canSubmit) && styles.submitDisabled,
|
||||
pressed && styles.submitPressed
|
||||
]}
|
||||
disabled={submitting || !canSubmit}
|
||||
onPress={() => void submit()}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<ReviewIcon size={14} color={colors.bgBase} strokeWidth={2.2} />
|
||||
)}
|
||||
<Text style={styles.submitText}>
|
||||
{pushBeforeCreate
|
||||
? draft
|
||||
? `Push & create draft ${copy.shortLabel}`
|
||||
: `Push & create ${copy.shortLabel}`
|
||||
: draft
|
||||
? `Create draft ${copy.shortLabel}`
|
||||
: `Create ${copy.shortLabel}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac
|
||||
import { ConfirmModal } from '../ConfirmModal'
|
||||
import { CommentMarkdown } from './CommentMarkdown'
|
||||
import { PRCommentComposer } from './PRCommentComposer'
|
||||
import { formatPrCommentRelativeTime } from './pr-comment-time'
|
||||
import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time'
|
||||
import { prCommentsStyles as styles } from './pr-comments-styles'
|
||||
|
||||
export type PRCommentRepoSlug = { owner: string; repo: string; host?: string }
|
||||
|
||||
@@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown'
|
||||
import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard'
|
||||
import { PRCommentComposer } from './PRCommentComposer'
|
||||
import {
|
||||
PR_COMMENT_AUDIENCE_FILTERS,
|
||||
filterPRCommentsByAudience,
|
||||
getPRCommentAudienceCounts,
|
||||
getPRCommentAudienceEmptyLabel,
|
||||
type PRCommentAudienceFilter
|
||||
} from './pr-comment-audience'
|
||||
} from '../../../../src/shared/pr-comment-audience'
|
||||
import {
|
||||
PR_COMMENT_AUDIENCE_FILTERS,
|
||||
getPRCommentAudienceEmptyLabel
|
||||
} from './pr-comment-audience-labels'
|
||||
import {
|
||||
getPRCommentGroupCount,
|
||||
getPRCommentGroupId,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
groupPRComments,
|
||||
isResolvedPRCommentGroup,
|
||||
type PRCommentGroup
|
||||
} from './pr-comment-groups'
|
||||
} from '../../../../src/shared/pr-comment-groups'
|
||||
import { prCommentsStyles as styles } from './pr-comments-styles'
|
||||
import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles'
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
|
||||
export const mobilePrComposeFormStyles = StyleSheet.create({
|
||||
root: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
headingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
headingTitle: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
heading: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
headingActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
genButton: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
genButtonPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
genButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
iconButton: {
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
branchFlow: {
|
||||
minHeight: 28,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
branchToken: {
|
||||
maxWidth: 116,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
branchTokenError: {
|
||||
color: colors.statusRed
|
||||
},
|
||||
fieldStack: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
titleInput: {
|
||||
minHeight: 40,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
bodyInput: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
// Why: a moderate fixed height avoids over-expanding inside the sidebar scroll.
|
||||
minHeight: 120,
|
||||
textAlignVertical: 'top'
|
||||
},
|
||||
baseRow: {
|
||||
minHeight: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
baseLabel: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
width: 36
|
||||
},
|
||||
baseControl: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
draftRow: {
|
||||
minHeight: 36,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
draftText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
notice: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.xs
|
||||
},
|
||||
noticeText: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 18
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed
|
||||
},
|
||||
submit: {
|
||||
marginTop: spacing.xs,
|
||||
minHeight: 44,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.textPrimary,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs
|
||||
},
|
||||
submitDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
submitPressed: {
|
||||
opacity: 0.8
|
||||
},
|
||||
submitText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '700'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience'
|
||||
|
||||
export const PR_COMMENT_AUDIENCE_FILTERS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'human', label: 'Humans' },
|
||||
{ value: 'bot', label: 'Bots' }
|
||||
] satisfies { value: PRCommentAudienceFilter; label: string }[]
|
||||
|
||||
export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string {
|
||||
switch (filter) {
|
||||
case 'bot':
|
||||
return 'No bot comments.'
|
||||
case 'human':
|
||||
return 'No human comments.'
|
||||
case 'all':
|
||||
return 'No comments yet.'
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { PRComment } from '../../../../src/shared/types'
|
||||
|
||||
// Thread grouping for the PR comment timeline, ported from the desktop helper
|
||||
// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants.
|
||||
// Inline review comments sharing a threadId collapse into a root + replies; all
|
||||
// other comments are standalone. Upstream order is preserved.
|
||||
export type PRCommentGroup =
|
||||
| { kind: 'standalone'; comment: PRComment }
|
||||
| { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] }
|
||||
|
||||
export function groupPRComments(comments: PRComment[]): PRCommentGroup[] {
|
||||
const threadMap = new Map<string, { root: PRComment; replies: PRComment[] }>()
|
||||
const groupsByFirstComment = new Map<PRComment, PRCommentGroup>()
|
||||
|
||||
for (const comment of comments) {
|
||||
if (!comment.threadId) {
|
||||
groupsByFirstComment.set(comment, { kind: 'standalone', comment })
|
||||
continue
|
||||
}
|
||||
const existing = threadMap.get(comment.threadId)
|
||||
if (existing) {
|
||||
existing.replies.push(comment)
|
||||
continue
|
||||
}
|
||||
threadMap.set(comment.threadId, { root: comment, replies: [] })
|
||||
}
|
||||
|
||||
const emitted = new Set<string>()
|
||||
const groups: PRCommentGroup[] = []
|
||||
for (const comment of comments) {
|
||||
if (!comment.threadId) {
|
||||
const group = groupsByFirstComment.get(comment)
|
||||
if (group) {
|
||||
groups.push(group)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (emitted.has(comment.threadId)) {
|
||||
continue
|
||||
}
|
||||
emitted.add(comment.threadId)
|
||||
const thread = threadMap.get(comment.threadId)
|
||||
if (thread) {
|
||||
groups.push({ kind: 'thread', threadId: comment.threadId, ...thread })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] {
|
||||
return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment]
|
||||
}
|
||||
|
||||
export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment {
|
||||
return group.kind === 'thread' ? group.root : group.comment
|
||||
}
|
||||
|
||||
export function getPRCommentGroupCount(group: PRCommentGroup): number {
|
||||
return getPRCommentGroupComments(group).length
|
||||
}
|
||||
|
||||
export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean {
|
||||
return getPRCommentGroupRoot(group).isResolved === true
|
||||
}
|
||||
|
||||
export function getPRCommentGroupId(group: PRCommentGroup): string {
|
||||
return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}`
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRComment } from '../../../../src/shared/types'
|
||||
import {
|
||||
createBotAuthorOverrideSet,
|
||||
filterPRCommentsByAudience,
|
||||
getPRCommentAudienceCounts,
|
||||
isBotPRComment
|
||||
} from './pr-comment-audience'
|
||||
import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups'
|
||||
import { formatPrCommentRelativeTime } from './pr-comment-time'
|
||||
|
||||
function comment(overrides: Partial<PRComment> & { id: number }): PRComment {
|
||||
return {
|
||||
author: 'octocat',
|
||||
authorAvatarUrl: '',
|
||||
body: '',
|
||||
createdAt: '',
|
||||
url: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('pr comment audience', () => {
|
||||
it('classifies app bots, [bot] suffix, and known automation logins', () => {
|
||||
expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false)
|
||||
expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true)
|
||||
expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('counts and filters by audience', () => {
|
||||
const comments = [
|
||||
comment({ id: 1, author: 'alice' }),
|
||||
comment({ id: 2, author: 'dependabot[bot]' }),
|
||||
comment({ id: 3, author: 'bob' })
|
||||
]
|
||||
expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 })
|
||||
expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2])
|
||||
expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3])
|
||||
expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('classifies desktop-marked bot authors via overrides (desktop parity)', () => {
|
||||
const overrides = createBotAuthorOverrideSet([' GretelFlux '])
|
||||
const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })]
|
||||
expect(isBotPRComment(comments[1])).toBe(false)
|
||||
expect(isBotPRComment(comments[1], overrides)).toBe(true)
|
||||
expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 })
|
||||
expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pr comment groups', () => {
|
||||
it('threads comments sharing a threadId as root + replies, preserving order', () => {
|
||||
const comments = [
|
||||
comment({ id: 1, author: 'a' }),
|
||||
comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }),
|
||||
comment({ id: 3, author: 'c', threadId: 't1' })
|
||||
]
|
||||
const groups = groupPRComments(comments)
|
||||
expect(groups).toHaveLength(2)
|
||||
expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] })
|
||||
expect(groups[1].kind).toBe('thread')
|
||||
if (groups[1].kind === 'thread') {
|
||||
expect(groups[1].root.id).toBe(2)
|
||||
expect(groups[1].replies.map((r) => r.id)).toEqual([3])
|
||||
}
|
||||
expect(isResolvedPRCommentGroup(groups[1])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pr comment relative time', () => {
|
||||
const now = Date.parse('2026-06-16T12:00:00Z')
|
||||
it('formats buckets and rejects bad input', () => {
|
||||
expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now')
|
||||
expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago')
|
||||
expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago')
|
||||
expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago')
|
||||
expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out),
|
||||
// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure +
|
||||
// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts).
|
||||
export function formatPrCommentRelativeTime(iso: string, nowMs: number): string {
|
||||
const ts = Date.parse(iso)
|
||||
if (Number.isNaN(ts)) {
|
||||
return ''
|
||||
}
|
||||
const delta = nowMs - ts
|
||||
if (delta < 60_000) {
|
||||
return 'just now'
|
||||
}
|
||||
const minutes = Math.floor(delta / 60_000)
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`
|
||||
}
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) {
|
||||
return `${days}d ago`
|
||||
}
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) {
|
||||
return `${months}mo ago`
|
||||
}
|
||||
return `${Math.floor(months / 12)}y ago`
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createElement, forwardRef, useImperativeHandle } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileMarkdownReader } from './MobileMarkdownReader'
|
||||
import type { MarkdownDocState } from './mobile-session-route-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dismissKeyboard: vi.fn(),
|
||||
reportKeyboardInset: null as ((bottom: number) => void) | null
|
||||
}))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Platform: { OS: 'ios', select: (choices: Record<string, unknown>) => choices.ios },
|
||||
Pressable: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('Pressable', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronDown: 'ChevronDown',
|
||||
Keyboard: 'KeyboardIcon',
|
||||
RefreshCw: 'RefreshCw'
|
||||
}))
|
||||
|
||||
vi.mock('../components/MobileRichMarkdownEditor', () => {
|
||||
const Editor = forwardRef(
|
||||
(props: { onKeyboardInsetChange?: (bottom: number) => void }, ref: unknown) => {
|
||||
mocks.reportKeyboardInset = props.onKeyboardInsetChange ?? null
|
||||
useImperativeHandle(ref as never, () => ({ dismissKeyboard: mocks.dismissKeyboard }))
|
||||
return createElement('MobileRichMarkdownEditor')
|
||||
}
|
||||
)
|
||||
return { MobileRichMarkdownEditor: Editor }
|
||||
})
|
||||
|
||||
const readyDoc: MarkdownDocState = {
|
||||
status: 'ready',
|
||||
content: '# Notes',
|
||||
localContent: '# Notes',
|
||||
baseVersion: '1',
|
||||
isDirty: false,
|
||||
editable: true
|
||||
}
|
||||
|
||||
describe('MobileMarkdownReader', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function render(keyboardLift: number, doc: MarkdownDocState = readyDoc): ReactTestRenderer {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileMarkdownReader, {
|
||||
documentId: 'doc-1',
|
||||
doc,
|
||||
keyboardLift,
|
||||
onRefresh: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onCopy: vi.fn(),
|
||||
onDiscard: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
return renderer as unknown as ReactTestRenderer
|
||||
}
|
||||
|
||||
function dismissButtons(instance: ReactTestRenderer) {
|
||||
return instance.root.findAll(
|
||||
(node) =>
|
||||
typeof node.type === 'string' && node.props?.accessibilityLabel === 'Dismiss keyboard'
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
mocks.reportKeyboardInset = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('offers keyboard dismissal while the keyboard covers the editor', () => {
|
||||
const instance = render(291)
|
||||
|
||||
const [button] = dismissButtons(instance)
|
||||
expect(button).toBeDefined()
|
||||
|
||||
act(() => button.props.onPress())
|
||||
|
||||
expect(mocks.dismissKeyboard).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the floating row entirely on a clean document with the keyboard closed', () => {
|
||||
const instance = render(0)
|
||||
|
||||
expect(dismissButtons(instance)).toHaveLength(0)
|
||||
expect(instance.root.findAllByType('Text')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps document actions available without offering dismissal when the keyboard is closed', () => {
|
||||
const instance = render(0, { ...readyDoc, isDirty: true })
|
||||
|
||||
expect(dismissButtons(instance)).toHaveLength(0)
|
||||
expect(instance.root.findAllByType('Text').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('treats a WebView-reported inset as an open keyboard when native lift reports none', () => {
|
||||
const instance = render(0)
|
||||
expect(dismissButtons(instance)).toHaveLength(0)
|
||||
|
||||
act(() => mocks.reportKeyboardInset?.(291))
|
||||
|
||||
expect(dismissButtons(instance)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { ChevronDown, Keyboard as KeyboardIcon, RefreshCw } from 'lucide-react-native'
|
||||
import {
|
||||
MobileRichMarkdownEditor,
|
||||
type MobileRichMarkdownEditorHandle
|
||||
} from '../components/MobileRichMarkdownEditor'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import {
|
||||
resolveMarkdownFloatingActionsBottom,
|
||||
shouldShowMarkdownFloatingActions
|
||||
} from './markdown-floating-actions-layout'
|
||||
import type { MarkdownDocState } from './mobile-session-route-types'
|
||||
import { styles } from './mobile-session-styles'
|
||||
|
||||
type Props = {
|
||||
documentId: string
|
||||
doc: MarkdownDocState | undefined
|
||||
onRefresh: () => void
|
||||
onChange: (content: string) => void
|
||||
onSave: () => void
|
||||
onCopy: () => void
|
||||
onDiscard: () => void
|
||||
keyboardLift: number
|
||||
}
|
||||
|
||||
export function MobileMarkdownReader({
|
||||
documentId,
|
||||
doc,
|
||||
onRefresh,
|
||||
onChange,
|
||||
onSave,
|
||||
onCopy,
|
||||
onDiscard,
|
||||
keyboardLift
|
||||
}: Props) {
|
||||
const editorRef = useRef<MobileRichMarkdownEditorHandle>(null)
|
||||
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
|
||||
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
|
||||
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
|
||||
const keyboardOpen = effectiveKeyboardLift > 0
|
||||
|
||||
if (!doc || doc.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (doc.status === 'error') {
|
||||
return (
|
||||
<View style={styles.markdownState}>
|
||||
<Text style={styles.markdownError}>{doc.message}</Text>
|
||||
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
|
||||
<RefreshCw size={14} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownRefreshText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const statusText = doc.saveError
|
||||
? doc.saveError
|
||||
: doc.readOnlyReason
|
||||
? 'Read only'
|
||||
: doc.stale
|
||||
? 'Changed on desktop'
|
||||
: null
|
||||
const showRefresh = Boolean((doc.stale && !doc.isDirty) || !doc.editable)
|
||||
const showCopy = Boolean(doc.saveError || !doc.editable)
|
||||
const showSave = Boolean(doc.isDirty || doc.saving)
|
||||
const showFloatingActions = shouldShowMarkdownFloatingActions({
|
||||
keyboardLift: effectiveKeyboardLift,
|
||||
hasStatus: statusText != null,
|
||||
showRefresh,
|
||||
showCopy,
|
||||
showSave
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={styles.markdownEditor}>
|
||||
<MobileRichMarkdownEditor
|
||||
ref={editorRef}
|
||||
key={documentId}
|
||||
content={doc.localContent}
|
||||
editable={doc.editable && !doc.saving}
|
||||
onChange={onChange}
|
||||
onKeyboardInsetChange={setWebviewKeyboardInset}
|
||||
/>
|
||||
{showFloatingActions ? (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.markdownFloatingBar,
|
||||
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
|
||||
{
|
||||
bottom: resolveMarkdownFloatingActionsBottom({
|
||||
keyboardLift: effectiveKeyboardLift,
|
||||
restingBottom: spacing.lg,
|
||||
liftedClearance: spacing.md
|
||||
})
|
||||
}
|
||||
]}
|
||||
>
|
||||
{statusText ? (
|
||||
<Text
|
||||
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{statusText}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.markdownFloatingActions}>
|
||||
{keyboardOpen ? (
|
||||
<Pressable
|
||||
style={[styles.markdownFloatingButton, styles.markdownKeyboardDismissButton]}
|
||||
onPress={() => editorRef.current?.dismissKeyboard()}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss keyboard"
|
||||
accessibilityHint="Hides the software keyboard and keeps the markdown editor open."
|
||||
>
|
||||
<View style={styles.keyboardDismissGlyph}>
|
||||
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
|
||||
<ChevronDown
|
||||
size={10}
|
||||
color={colors.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
style={styles.keyboardDismissChevron}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showCopy ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showRefresh ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
|
||||
<RefreshCw size={13} color={colors.textPrimary} />
|
||||
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{doc.isDirty ? (
|
||||
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
|
||||
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showSave ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.markdownFloatingButton,
|
||||
styles.markdownSaveButton,
|
||||
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
|
||||
]}
|
||||
disabled={!doc.editable || !doc.isDirty || doc.saving}
|
||||
onPress={onSave}
|
||||
>
|
||||
{doc.saving ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Text style={styles.markdownFloatingButtonText}>Save</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -298,11 +298,12 @@ describe('MobileNativeChatComposer', () => {
|
||||
})
|
||||
|
||||
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
|
||||
const onChangeText = vi.fn()
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '/c',
|
||||
onChangeText: vi.fn(),
|
||||
onChangeText,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
agent: 'claude'
|
||||
})
|
||||
@@ -325,6 +326,7 @@ describe('MobileNativeChatComposer', () => {
|
||||
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
|
||||
)[0] as { props: { onPress: () => void } }
|
||||
await act(async () => firstSuggestion.props.onPress())
|
||||
expect(onChangeText).toHaveBeenCalledWith('/clear ')
|
||||
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
|
||||
expect(input().props.selection).toEqual({ start: 7, end: 7 })
|
||||
// The next native selection event releases control so manual placement still works.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import {
|
||||
getQuickCommandDisplayPreview,
|
||||
getTerminalQuickCommandBody,
|
||||
isAgentQuickCommand
|
||||
} from '../terminal/quick-commands'
|
||||
|
||||
type QuickCommandRowProps = {
|
||||
command: TerminalQuickCommand
|
||||
first: boolean
|
||||
onLaunch: (command: TerminalQuickCommand) => void
|
||||
onEdit: (command: TerminalQuickCommand) => void
|
||||
onDelete: (command: TerminalQuickCommand) => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
type CopyFeedback = {
|
||||
body: string
|
||||
status: 'copied' | 'failed'
|
||||
}
|
||||
|
||||
export function QuickCommandRow({
|
||||
command,
|
||||
first,
|
||||
onLaunch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled
|
||||
}: QuickCommandRowProps) {
|
||||
const isAgent = isAgentQuickCommand(command)
|
||||
const body = getTerminalQuickCommandBody(command)
|
||||
const canCopy = body.trim().length > 0
|
||||
// Why: key feedback to the copied body so a prop change drops stale labels
|
||||
// without setState-in-effect (react-doctor no-adjust-state-on-prop-change).
|
||||
const [feedback, setFeedback] = useState<CopyFeedback | null>(null)
|
||||
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const copyStatus: 'idle' | 'copied' | 'failed' =
|
||||
feedback != null && feedback.body === body ? feedback.status : 'idle'
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
if (copyResetTimerRef.current) {
|
||||
clearTimeout(copyResetTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Drop any pending reset timer when the body changes; display status is already idle.
|
||||
useEffect(() => {
|
||||
if (copyResetTimerRef.current) {
|
||||
clearTimeout(copyResetTimerRef.current)
|
||||
copyResetTimerRef.current = null
|
||||
}
|
||||
}, [body])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
if (!canCopy || disabled) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await Clipboard.setStringAsync(body)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setFeedback({ body, status: 'copied' })
|
||||
} catch {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setFeedback({ body, status: 'failed' })
|
||||
}
|
||||
if (copyResetTimerRef.current) {
|
||||
clearTimeout(copyResetTimerRef.current)
|
||||
}
|
||||
copyResetTimerRef.current = setTimeout(() => {
|
||||
copyResetTimerRef.current = null
|
||||
if (mountedRef.current) {
|
||||
setFeedback(null)
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const copyDisabled = disabled || !canCopy
|
||||
const copyLabel =
|
||||
copyStatus === 'copied'
|
||||
? 'Copied'
|
||||
: copyStatus === 'failed'
|
||||
? "Couldn't copy"
|
||||
: canCopy
|
||||
? `Copy ${command.label}`
|
||||
: 'Nothing to copy'
|
||||
const copyIconColor =
|
||||
copyStatus === 'copied'
|
||||
? colors.statusGreen
|
||||
: copyStatus === 'failed'
|
||||
? colors.statusRed
|
||||
: colors.textSecondary
|
||||
|
||||
return (
|
||||
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onLaunch(command)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Run ${command.label}`}
|
||||
>
|
||||
<View style={styles.rowIcon}>
|
||||
{isAgent ? (
|
||||
<MobileAgentIcon agentId={command.agent} size={16} />
|
||||
) : (
|
||||
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{command.label}
|
||||
</Text>
|
||||
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
|
||||
{getQuickCommandDisplayPreview(command)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.rowAction,
|
||||
// Why: row already dims when `disabled`; only dim again for empty body.
|
||||
!canCopy && styles.disabled,
|
||||
pressed && !copyDisabled && styles.pressed
|
||||
]}
|
||||
disabled={copyDisabled}
|
||||
onPress={() => void handleCopy()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={copyLabel}
|
||||
accessibilityState={{ disabled: copyDisabled }}
|
||||
>
|
||||
{copyStatus === 'copied' ? (
|
||||
<Check size={15} color={copyIconColor} />
|
||||
) : (
|
||||
<Copy size={15} color={copyIconColor} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onEdit(command)}
|
||||
accessibilityLabel={`Edit ${command.label}`}
|
||||
>
|
||||
<Pencil size={15} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onDelete(command)}
|
||||
accessibilityLabel={`Delete ${command.label}`}
|
||||
>
|
||||
<Trash2 size={15} color={colors.statusRed} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressed: { backgroundColor: colors.bgRaised },
|
||||
disabled: { opacity: 0.45 },
|
||||
row: { flexDirection: 'row', alignItems: 'center' },
|
||||
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingLeft: spacing.md,
|
||||
minWidth: 0
|
||||
},
|
||||
rowIcon: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
rowText: { flex: 1, minWidth: 0 },
|
||||
rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
|
||||
rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
|
||||
mono: { fontFamily: typography.monoFamily },
|
||||
rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }
|
||||
})
|
||||
@@ -17,6 +17,7 @@ vi.mock('react-native', () => ({
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Check: 'Check',
|
||||
Copy: 'Copy',
|
||||
Pencil: 'Pencil',
|
||||
Play: 'Play',
|
||||
Plus: 'Plus',
|
||||
@@ -24,6 +25,8 @@ vi.mock('lucide-react-native', () => ({
|
||||
Trash2: 'Trash2'
|
||||
}))
|
||||
|
||||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||||
|
||||
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
|
||||
|
||||
describe('QuickCommandsList search', () => {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native'
|
||||
import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { Check, Plus, Search } from 'lucide-react-native'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
|
||||
import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
|
||||
import {
|
||||
getQuickCommandDisplayPreview,
|
||||
isAgentQuickCommand,
|
||||
supportsTerminalAgentQuickCommand
|
||||
} from '../terminal/quick-commands'
|
||||
import { supportsTerminalAgentQuickCommand } from '../terminal/quick-commands'
|
||||
import { QuickCommandRow } from './QuickCommandRow'
|
||||
|
||||
export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) =>
|
||||
supportsTerminalAgentQuickCommand(entry.id)
|
||||
@@ -162,67 +159,6 @@ function QuickCommandGroup({
|
||||
)
|
||||
}
|
||||
|
||||
function QuickCommandRow({
|
||||
command,
|
||||
first,
|
||||
onLaunch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled
|
||||
}: {
|
||||
command: TerminalQuickCommand
|
||||
first: boolean
|
||||
onLaunch: (command: TerminalQuickCommand) => void
|
||||
onEdit: (command: TerminalQuickCommand) => void
|
||||
onDelete: (command: TerminalQuickCommand) => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
const isAgent = isAgentQuickCommand(command)
|
||||
return (
|
||||
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onLaunch(command)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Run ${command.label}`}
|
||||
>
|
||||
<View style={styles.rowIcon}>
|
||||
{isAgent ? (
|
||||
<MobileAgentIcon agentId={command.agent} size={16} />
|
||||
) : (
|
||||
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{command.label}
|
||||
</Text>
|
||||
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
|
||||
{getQuickCommandDisplayPreview(command)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onEdit(command)}
|
||||
accessibilityLabel={`Edit ${command.label}`}
|
||||
>
|
||||
<Pencil size={15} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
|
||||
disabled={disabled}
|
||||
onPress={() => onDelete(command)}
|
||||
accessibilityLabel={`Delete ${command.label}`}
|
||||
>
|
||||
<Trash2 size={15} color={colors.statusRed} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuickCommandAgentPicker({
|
||||
selected,
|
||||
onSelect
|
||||
@@ -292,15 +228,6 @@ const styles = StyleSheet.create({
|
||||
group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' },
|
||||
row: { flexDirection: 'row', alignItems: 'center' },
|
||||
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingLeft: spacing.md,
|
||||
minWidth: 0
|
||||
},
|
||||
rowIcon: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
@@ -309,11 +236,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
rowText: { flex: 1, minWidth: 0 },
|
||||
rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
|
||||
rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
|
||||
mono: { fontFamily: typography.monoFamily },
|
||||
rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' },
|
||||
agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary },
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout'
|
||||
import {
|
||||
resolveMarkdownFloatingActionsBottom,
|
||||
shouldShowMarkdownFloatingActions
|
||||
} from './markdown-floating-actions-layout'
|
||||
|
||||
describe('resolveMarkdownFloatingActionsBottom', () => {
|
||||
it('keeps markdown actions at their resting bottom when the keyboard is closed', () => {
|
||||
@@ -22,3 +25,28 @@ describe('resolveMarkdownFloatingActionsBottom', () => {
|
||||
).toBe(303)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowMarkdownFloatingActions', () => {
|
||||
const idle = {
|
||||
keyboardLift: 0,
|
||||
hasStatus: false,
|
||||
showRefresh: false,
|
||||
showCopy: false,
|
||||
showSave: false
|
||||
}
|
||||
|
||||
it('shows the floating row for keyboard dismissal without document actions', () => {
|
||||
expect(shouldShowMarkdownFloatingActions({ ...idle, keyboardLift: 291 })).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the floating row on a clean document with the keyboard closed', () => {
|
||||
expect(shouldShowMarkdownFloatingActions(idle)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['hasStatus', 'showRefresh', 'showCopy', 'showSave'] as const)(
|
||||
'shows the floating row for %s alone',
|
||||
(field) => {
|
||||
expect(shouldShowMarkdownFloatingActions({ ...idle, [field]: true })).toBe(true)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -9,3 +9,19 @@ export function resolveMarkdownFloatingActionsBottom({
|
||||
}): number {
|
||||
return keyboardLift > 0 ? keyboardLift + liftedClearance : restingBottom
|
||||
}
|
||||
|
||||
export function shouldShowMarkdownFloatingActions({
|
||||
keyboardLift,
|
||||
hasStatus,
|
||||
showRefresh,
|
||||
showCopy,
|
||||
showSave
|
||||
}: {
|
||||
keyboardLift: number
|
||||
hasStatus: boolean
|
||||
showRefresh: boolean
|
||||
showCopy: boolean
|
||||
showSave: boolean
|
||||
}): boolean {
|
||||
return keyboardLift > 0 || hasStatus || showRefresh || showCopy || showSave
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
openMobileNativeChatSendBudget,
|
||||
clearMobileNativeChatInput,
|
||||
sendMobileNativeChatMessage,
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
typeMobileNativeChatCommandWithOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
function clientWithResponse(response: unknown): RpcClient {
|
||||
return {
|
||||
sendRequest: vi.fn().mockResolvedValue(response)
|
||||
@@ -290,6 +293,65 @@ describe('sendMobileNativeChatMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('typeMobileNativeChatCommandWithOutcome', () => {
|
||||
it('writes the Codex picker command as keys instead of one pasted text write', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
const result = typeMobileNativeChatCommandWithOutcome({
|
||||
client,
|
||||
terminal: 'term',
|
||||
command: '/model'
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(result).resolves.toBe('accepted')
|
||||
expect(
|
||||
vi.mocked(client.sendRequest).mock.calls.map((call) => {
|
||||
const params = call[1] as { text: string; enter: boolean }
|
||||
return { text: params.text, enter: params.enter }
|
||||
})
|
||||
).toEqual(
|
||||
['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({
|
||||
text,
|
||||
enter: false
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
it('retires a parked launch draft only with the final typed Enter', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = clientWithResponse({
|
||||
id: 'request',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
const result = typeMobileNativeChatCommandWithOutcome({
|
||||
client,
|
||||
terminal: 'term',
|
||||
command: '/model',
|
||||
resolvedLaunchDraft: { text: 'seed', createdAt: 7 }
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
await result
|
||||
|
||||
const params = vi.mocked(client.sendRequest).mock.calls.map((call) => call[1]) as Array<{
|
||||
text: string
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
}>
|
||||
expect(params.slice(0, -1).every((entry) => entry.resolvedLaunchDraft === undefined)).toBe(true)
|
||||
expect(params.at(-1)).toMatchObject({
|
||||
text: '\r',
|
||||
resolvedLaunchDraft: { text: 'seed', createdAt: 7 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMobileNativeChatInput', () => {
|
||||
const accepted = {
|
||||
id: 'request',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing'
|
||||
|
||||
type MobileTerminalClient = {
|
||||
id: string
|
||||
@@ -93,6 +94,35 @@ export async function sendMobileNativeChatMessage(
|
||||
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
|
||||
}
|
||||
|
||||
export async function typeMobileNativeChatCommandWithOutcome(args: {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
command: string
|
||||
resolvedLaunchDraft?: { text: string; createdAt: number }
|
||||
mobileClient?: MobileTerminalClient
|
||||
deadline?: number
|
||||
}): Promise<MobileNativeChatSendOutcome> {
|
||||
let writeIndex = 0
|
||||
return typeAgentTuiCommand({
|
||||
command: args.command,
|
||||
write: (key) => {
|
||||
const isSubmit = writeIndex === args.command.length + 1
|
||||
writeIndex += 1
|
||||
return sendMobileNativeChatMessageWithOutcome({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
text: key,
|
||||
enter: false,
|
||||
...(isSubmit && args.resolvedLaunchDraft
|
||||
? { resolvedLaunchDraft: args.resolvedLaunchDraft }
|
||||
: {}),
|
||||
...(args.mobileClient ? { mobileClient: args.mobileClient } : {}),
|
||||
...(args.deadline === undefined ? {} : { deadline: args.deadline })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the agent's input line as its OWN write, before any body.
|
||||
*
|
||||
|
||||
@@ -159,6 +159,11 @@ export const mobileSessionReviewCommentStyles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
markdownKeyboardDismissButton: {
|
||||
width: 34,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 0
|
||||
},
|
||||
markdownSaveButton: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
|
||||
@@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const sendWithOutcome = vi.fn()
|
||||
const clearInputWrite = vi.fn()
|
||||
const typeCommandWithOutcome = vi.fn()
|
||||
vi.mock('./mobile-native-chat-send', () => ({
|
||||
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
|
||||
typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args),
|
||||
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
|
||||
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
|
||||
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
|
||||
@@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
clearInputWrite.mockReset()
|
||||
clearInputWrite.mockResolvedValue(true)
|
||||
typeCommandWithOutcome.mockReset()
|
||||
typeCommandWithOutcome.mockResolvedValue('accepted')
|
||||
acceptSend.mockReset()
|
||||
holdUnconfirmedSend.mockReset()
|
||||
onCommandSend.mockReset()
|
||||
@@ -215,24 +219,46 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
expect(onCommandSend).toHaveBeenCalledWith('/clear')
|
||||
})
|
||||
|
||||
it('never creates an optimistic echo for an unknown slash token', async () => {
|
||||
// `/model` is not in Claude's autocomplete catalog, but the session-option
|
||||
// recorder still recognizes it without claiming a generic command ran.
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('/model sonnet')
|
||||
})
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
|
||||
})
|
||||
it.each(['claude', 'openclaude'] as const)(
|
||||
'keeps %s composer slash sends pasted',
|
||||
async (agent) => {
|
||||
// `/model` is not in Claude's autocomplete catalog, but the session-option
|
||||
// recorder still recognizes it without claiming a generic command ran.
|
||||
mount(() => null, agent)
|
||||
await act(async () => {
|
||||
await api!.send('/model sonnet')
|
||||
})
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
|
||||
expect(sendWithOutcome).toHaveBeenCalledOnce()
|
||||
expect(typeCommandWithOutcome).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('classifies per agent: /model is a catalog command for Codex', async () => {
|
||||
it('types Codex composer slash sends instead of pasting them', async () => {
|
||||
mount(() => null, 'codex')
|
||||
await act(async () => {
|
||||
await api!.send('/model')
|
||||
})
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).toHaveBeenCalledWith('/model')
|
||||
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: '/model', terminal: 'term' })
|
||||
)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps Codex skill sends pasted', async () => {
|
||||
mount(() => null, 'codex')
|
||||
await act(async () => {
|
||||
await api!.send('$ref-oss')
|
||||
})
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).toHaveBeenCalledWith('$ref-oss')
|
||||
expect(sendWithOutcome).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: '$ref-oss', terminal: 'term' })
|
||||
)
|
||||
expect(typeCommandWithOutcome).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('holds only chat sends for transcript confirmation on a lost ack', async () => {
|
||||
@@ -248,16 +274,44 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispatchCommand surfaces the outcome without echo or composer sync', async () => {
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
it.each(['claude', 'openclaude'] as const)(
|
||||
'keeps %s session-option commands pasted',
|
||||
async (agent) => {
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }), agent)
|
||||
let outcome: string | undefined
|
||||
await act(async () => {
|
||||
outcome = await api!.dispatchCommand('/model sonnet', { delivery: 'type' })
|
||||
})
|
||||
expect(outcome).toBe('accepted')
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).not.toHaveBeenCalled()
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('routes typed picker commands around the pasted composer-text send', async () => {
|
||||
mount(() => null, 'codex')
|
||||
let outcome: string | undefined
|
||||
await act(async () => {
|
||||
outcome = await api!.dispatchCommand('/model sonnet')
|
||||
outcome = await api!.dispatchCommand('/model', { delivery: 'type' })
|
||||
})
|
||||
expect(outcome).toBe('accepted')
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).not.toHaveBeenCalled()
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: '/model', terminal: 'term' })
|
||||
)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('types Codex session-option commands without caller delivery metadata', async () => {
|
||||
mount(() => null, 'codex')
|
||||
await act(async () => {
|
||||
await api!.dispatchCommand('/model')
|
||||
})
|
||||
|
||||
expect(typeCommandWithOutcome).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: '/model', terminal: 'term' })
|
||||
)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binds classification to the agent that started the send', async () => {
|
||||
|
||||
@@ -4,8 +4,11 @@ import {
|
||||
clearMobileNativeChatInput,
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
typeMobileNativeChatCommandWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog'
|
||||
import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
|
||||
import {
|
||||
@@ -32,7 +35,10 @@ export type MobileNativeChatMessageSend = {
|
||||
answerQuestion: (text: string) => Promise<boolean>
|
||||
/** Session-option command dispatch (e.g. `/model sonnet`) — never touches the
|
||||
* composer draft; callers need the outcome to track dispatched state. */
|
||||
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
|
||||
dispatchCommand: (
|
||||
text: string,
|
||||
options?: { delivery?: CatalogCommandDelivery }
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
}
|
||||
|
||||
/** The native-chat send seam: one write path shared by composer sends, image
|
||||
@@ -150,40 +156,42 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
return 'rejected'
|
||||
}
|
||||
}
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
text,
|
||||
// Why: pre-clear only when nothing was deliberately pasted first. The heal
|
||||
// above fires only for terminals a mobile image paste marked, so a desktop
|
||||
// launch-draft prefill parked on the input line would otherwise glue onto
|
||||
// this message. An image send already led its own paste with Ctrl+U, and a
|
||||
// second one here would wipe the image it just pasted (desktop's image path
|
||||
// likewise clears once, before the paste, and never again).
|
||||
//
|
||||
// Also skipped once the dedicated clear above ran: the line is already
|
||||
// empty, and a Ctrl+U written immediately before body text in the SAME
|
||||
// write reaches the agent as a literal control character rather than a
|
||||
// keypress (observed live as a stray \x15 heading the received message).
|
||||
clearInputFirst: !images?.length && !seededLaunchDraft,
|
||||
...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
|
||||
? {
|
||||
resolvedLaunchDraft: {
|
||||
text: seededLaunchDraft.text,
|
||||
createdAt: seededLaunchDraft.createdAt
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
const classification = classifyMobileNativeChatSend(agent, text)
|
||||
const mobileClient = deviceTokenRef.current
|
||||
? { id: deviceTokenRef.current, type: 'mobile' as const }
|
||||
: undefined
|
||||
const resolvedLaunchDraft =
|
||||
syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
|
||||
? { text: seededLaunchDraft.text, createdAt: seededLaunchDraft.createdAt }
|
||||
: undefined
|
||||
const outcome =
|
||||
agent === 'codex' &&
|
||||
classification !== 'chat' &&
|
||||
isSlashCommandDraft(text) &&
|
||||
!images?.length
|
||||
? await typeMobileNativeChatCommandWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
command: text,
|
||||
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
|
||||
...(mobileClient ? { mobileClient } : {}),
|
||||
deadline
|
||||
})
|
||||
: await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
text,
|
||||
// Why: an image send already cleared before its paste; a second
|
||||
// clear here would wipe the image before submission.
|
||||
clearInputFirst: !images?.length && !seededLaunchDraft,
|
||||
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}),
|
||||
deadline,
|
||||
...(mobileClient ? { mobileClient } : {})
|
||||
})
|
||||
// Why (desktop parity): a slash/skill send dispatches into the agent's own
|
||||
// TUI, not the conversation — the transcript never echoes it as a user
|
||||
// turn, so an optimistic bubble would never reconcile and the
|
||||
// unconfirmed hold could never observe a landing.
|
||||
const classification = classifyMobileNativeChatSend(agent, text)
|
||||
if (outcome === 'unknown') {
|
||||
if (classification === 'chat') {
|
||||
// Why: an ack-lost send usually WAS delivered (issue seen on cellular
|
||||
@@ -269,12 +277,41 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
// spaces a send's body and its Enter ~500ms apart — so without this lock an
|
||||
// apply lands between them and is submitted as part of the user's prompt.
|
||||
const dispatchCommand = useCallback(
|
||||
async (text: string): Promise<MobileNativeChatSendOutcome> => {
|
||||
async (
|
||||
text: string,
|
||||
_options?: { delivery?: CatalogCommandDelivery }
|
||||
): Promise<MobileNativeChatSendOutcome> => {
|
||||
const terminal = handleRef.current
|
||||
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
|
||||
return 'rejected'
|
||||
}
|
||||
try {
|
||||
if (agentRef.current === 'codex') {
|
||||
if (!client || !terminal || !enabled) {
|
||||
return 'rejected'
|
||||
}
|
||||
const deadline = openMobileNativeChatSendBudget()
|
||||
const mobileClient = deviceTokenRef.current
|
||||
? { id: deviceTokenRef.current, type: 'mobile' as const }
|
||||
: undefined
|
||||
if (
|
||||
!(await healMobileNativeChatStaleInput({
|
||||
client,
|
||||
terminal,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
deadline
|
||||
}))
|
||||
) {
|
||||
return 'rejected'
|
||||
}
|
||||
return typeMobileNativeChatCommandWithOutcome({
|
||||
client,
|
||||
terminal,
|
||||
command: text,
|
||||
...(mobileClient ? { mobileClient } : {}),
|
||||
deadline
|
||||
})
|
||||
}
|
||||
return await sendMessage(text, undefined, false, false)
|
||||
} finally {
|
||||
if (terminal) {
|
||||
@@ -282,7 +319,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleRef, sendMessage]
|
||||
[client, deviceTokenRef, enabled, handleRef, sendMessage]
|
||||
)
|
||||
|
||||
return { send, sendWithOutcome, answerQuestion, dispatchCommand }
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let api: MobileNativeChatSessionOptionsController | null = null
|
||||
let hookArgs: HookArgs
|
||||
const dispatchCommand = vi.fn<(command: string) => Promise<MobileNativeChatSendOutcome>>()
|
||||
const dispatchCommand = vi.fn<HookArgs['dispatchCommand']>()
|
||||
const onAgentPicker = vi.fn()
|
||||
|
||||
function Probe(): null {
|
||||
@@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => {
|
||||
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('applies Codex model changes through the native command', async () => {
|
||||
it('types the Codex picker command and switches to the terminal', async () => {
|
||||
mount({ agent: 'codex' })
|
||||
expect(api!.snapshot[0]?.action).toBeUndefined()
|
||||
expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' })
|
||||
await act(async () => {
|
||||
await api!.setOption('model', 'gpt-5.5')
|
||||
await api!.invokeAction('model')
|
||||
})
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5')
|
||||
expect(onAgentPicker).not.toHaveBeenCalled()
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('types the Codex effort picker command', async () => {
|
||||
mount({ agent: 'codex', reportedModel: 'gpt-5.5' })
|
||||
await act(async () => {
|
||||
await api!.invokeAction('effort')
|
||||
})
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' })
|
||||
expect(onAgentPicker).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('seeds the current model from a hook-reported provider model', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import {
|
||||
getAgentSessionOptionCatalog,
|
||||
type AgentSessionOptionCatalog,
|
||||
type CatalogCommandDelivery,
|
||||
type CatalogModel
|
||||
} from '../../../src/shared/agent-session-option-catalog'
|
||||
import type {
|
||||
@@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: {
|
||||
scopeKey: string | null
|
||||
/** Provider model from live agent status, when the hook reported one. */
|
||||
reportedModel: string | null
|
||||
dispatchCommand: (command: string) => Promise<MobileNativeChatSendOutcome>
|
||||
dispatchCommand: (
|
||||
command: string,
|
||||
options?: { delivery?: CatalogCommandDelivery }
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** A model change that must happen in the agent's own TUI picker was
|
||||
* dispatched — bring the terminal view forward. */
|
||||
onAgentPicker?: () => void
|
||||
@@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: {
|
||||
?.options.find((option) => option.id === id)?.apply
|
||||
const midSession = apply?.midSession
|
||||
if (midSession?.kind === 'agent-picker') {
|
||||
const outcome = await dispatchCommand(midSession.command)
|
||||
const outcome = midSession.delivery
|
||||
? await dispatchCommand(midSession.command, { delivery: midSession.delivery })
|
||||
: await dispatchCommand(midSession.command)
|
||||
if (outcome === 'rejected') {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -109,7 +109,10 @@ describe('useMobileNativeChatSession', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(state?.messages).toEqual([])
|
||||
// The retained window keeps rendering while the source is gone; what must
|
||||
// never land is the page that resolved after it disappeared.
|
||||
expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-page')
|
||||
expect(state?.messages).toHaveLength(40)
|
||||
expect(state?.status).toBe('idle')
|
||||
expect(state?.loadingEarlier).toBe(false)
|
||||
})
|
||||
@@ -557,6 +560,30 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the retained transcript rendered when the stream reports an error', async () => {
|
||||
// A transient read failure must not blank a conversation the user is looking
|
||||
// at; the last settled list for this identity stays until a read supersedes it.
|
||||
let emitFrame: (frame: unknown) => void = () => {}
|
||||
const client = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
|
||||
emitFrame = onData
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
return () => {}
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
await mountAt(client, 'session-a')
|
||||
expect(renders.at(-1)).toMatchObject({ status: 'ready', ids: ['a-1'] })
|
||||
|
||||
renders.length = 0
|
||||
await act(async () => emitFrame({ type: 'error', message: 'stream broke' }))
|
||||
|
||||
expect(renders.at(-1)).toMatchObject({
|
||||
status: 'error',
|
||||
transcriptLoading: false,
|
||||
ids: ['a-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('never holds a cached list across a host/workspace source change', async () => {
|
||||
const firstClient = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
|
||||
|
||||
@@ -273,11 +273,12 @@ export function useMobileNativeChatSession(args: {
|
||||
})()
|
||||
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
|
||||
|
||||
// Held for any unsettled read, not just an in-flight one: a stream error or a
|
||||
// dropped client would otherwise trade the conversation for an error card.
|
||||
const visibleMessages = transcriptRetentionRef.current.visible({
|
||||
identity,
|
||||
messages,
|
||||
settled: settledReady,
|
||||
loading: status === 'loading'
|
||||
settled: settledReady
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapBaseRefResults } from './mobile-base-ref-search'
|
||||
|
||||
describe('mapBaseRefResults', () => {
|
||||
it('extracts a clean string list from a well-formed payload', () => {
|
||||
expect(mapBaseRefResults({ refs: ['main', 'dev'] })).toEqual(['main', 'dev'])
|
||||
})
|
||||
|
||||
it('returns [] for malformed payloads and drops bad entries', () => {
|
||||
expect(mapBaseRefResults(null)).toEqual([])
|
||||
expect(mapBaseRefResults({})).toEqual([])
|
||||
expect(mapBaseRefResults({ refs: 'x' })).toEqual([])
|
||||
expect(mapBaseRefResults({ refs: ['ok', 2, '', null, 'two'] })).toEqual(['ok', 'two'])
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create'
|
||||
|
||||
export function mapBaseRefResults(raw: unknown): string[] {
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
return []
|
||||
}
|
||||
const refs = (raw as { refs?: unknown }).refs
|
||||
if (!Array.isArray(refs)) {
|
||||
return []
|
||||
}
|
||||
return refs.filter((r): r is string => typeof r === 'string' && r.length > 0)
|
||||
}
|
||||
|
||||
export async function searchBaseRefs(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
query: string,
|
||||
limit = 20
|
||||
): Promise<string[]> {
|
||||
const response = await client.sendRequest('repo.searchRefs', {
|
||||
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
|
||||
query,
|
||||
limit
|
||||
})
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
return mapBaseRefResults((response as RpcSuccess).result)
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import type { MobileGitUpstreamStatus } from './mobile-git-status'
|
||||
|
||||
export type MobileSourceControlPrimaryActionKind =
|
||||
| 'commit'
|
||||
| 'stage'
|
||||
| 'push'
|
||||
| 'pull'
|
||||
| 'sync'
|
||||
| 'publish'
|
||||
|
||||
export type MobileSourceControlRemoteOpKind =
|
||||
| 'push'
|
||||
| 'force_push'
|
||||
| 'pull'
|
||||
| 'sync'
|
||||
| 'fetch'
|
||||
| 'fast_forward'
|
||||
| 'publish'
|
||||
| 'rebase'
|
||||
|
||||
export type MobileSourceControlPrimaryActionTitleIntent =
|
||||
| 'commit_in_progress'
|
||||
| 'force_push_in_progress'
|
||||
| 'action_in_progress'
|
||||
| 'remote_operation_in_progress'
|
||||
| 'remote_operation_blocks_commit'
|
||||
| 'resolve_conflicts_before_commit'
|
||||
| 'commit_staged_changes'
|
||||
| 'enter_commit_message'
|
||||
| 'stage_all_changes'
|
||||
| 'stage_file_to_commit'
|
||||
| 'checkout_branch_before_publish'
|
||||
| 'publish_branch'
|
||||
| 'force_push_with_lease'
|
||||
| 'sync_counts'
|
||||
| 'pull_count'
|
||||
| 'push_count'
|
||||
| 'nothing_to_commit_up_to_date'
|
||||
|
||||
export type MobileSourceControlPrimaryActionDecision = {
|
||||
kind: MobileSourceControlPrimaryActionKind
|
||||
disabled: boolean
|
||||
labelIntent: MobileSourceControlPrimaryActionKind | 'force_push'
|
||||
titleIntent: MobileSourceControlPrimaryActionTitleIntent
|
||||
count?: number
|
||||
ahead?: number
|
||||
behind?: number
|
||||
upstreamName?: string
|
||||
requiresForceWithLease?: boolean
|
||||
}
|
||||
|
||||
export type MobileSourceControlPrimaryActionDecisionInputs = {
|
||||
stagedCount: number
|
||||
hasUnstagedChanges: boolean
|
||||
hasStageableChanges: boolean
|
||||
hasPartiallyStagedChanges: boolean
|
||||
hasMessage: boolean
|
||||
hasUnresolvedConflicts: boolean
|
||||
isCommitting: boolean
|
||||
isRemoteOperationActive: boolean
|
||||
upstreamStatus: MobileGitUpstreamStatus | undefined
|
||||
inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null
|
||||
branchCommitsAhead?: number
|
||||
hasCurrentBranch?: boolean
|
||||
}
|
||||
|
||||
// Why: Metro cannot load runtime modules from the desktop/root `src/shared`
|
||||
// tree. Keep this mobile mirror narrow and parity-tested against the shared
|
||||
// commit-area decision core so the semantic ladder cannot drift silently.
|
||||
export function resolveMobileSourceControlCommitAreaPrimaryActionDecision(
|
||||
inputs: MobileSourceControlPrimaryActionDecisionInputs
|
||||
): MobileSourceControlPrimaryActionDecision {
|
||||
const {
|
||||
stagedCount,
|
||||
hasUnstagedChanges,
|
||||
hasStageableChanges,
|
||||
hasMessage,
|
||||
hasUnresolvedConflicts,
|
||||
isCommitting,
|
||||
isRemoteOperationActive,
|
||||
upstreamStatus,
|
||||
branchCommitsAhead,
|
||||
hasCurrentBranch = true
|
||||
} = inputs
|
||||
|
||||
if (isCommitting) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'commit_in_progress',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isRemoteOperationActive) {
|
||||
return resolveMobilePrimaryActionDuringRemoteOp(inputs)
|
||||
}
|
||||
|
||||
if (hasUnresolvedConflicts) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'resolve_conflicts_before_commit',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
const hasStaged = stagedCount > 0
|
||||
|
||||
if (hasStaged && hasMessage) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'commit_staged_changes',
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStaged && !hasMessage) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'enter_commit_message',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasStaged && hasStageableChanges) {
|
||||
return {
|
||||
kind: 'stage',
|
||||
labelIntent: 'stage',
|
||||
titleIntent: 'stage_all_changes',
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstreamStatus) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'stage_file_to_commit',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstreamStatus.hasUpstream) {
|
||||
if (!hasCurrentBranch) {
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: 'checkout_branch_before_publish',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'publish',
|
||||
labelIntent: 'publish',
|
||||
titleIntent: 'publish_branch',
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
|
||||
if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) {
|
||||
return {
|
||||
kind: 'push',
|
||||
labelIntent: 'force_push',
|
||||
titleIntent: 'force_push_with_lease',
|
||||
disabled: false,
|
||||
count: branchCommitsAhead,
|
||||
upstreamName: upstreamStatus.upstreamName,
|
||||
requiresForceWithLease: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'sync',
|
||||
labelIntent: 'sync',
|
||||
titleIntent: 'sync_counts',
|
||||
disabled: false,
|
||||
ahead: upstreamStatus.ahead,
|
||||
behind: upstreamStatus.behind
|
||||
}
|
||||
}
|
||||
|
||||
if (upstreamStatus.behind > 0) {
|
||||
return {
|
||||
kind: 'pull',
|
||||
labelIntent: 'pull',
|
||||
titleIntent: 'pull_count',
|
||||
disabled: false,
|
||||
count: upstreamStatus.behind
|
||||
}
|
||||
}
|
||||
|
||||
if (upstreamStatus.ahead > 0) {
|
||||
return {
|
||||
kind: 'push',
|
||||
labelIntent: 'push',
|
||||
titleIntent: 'push_count',
|
||||
disabled: false,
|
||||
count: upstreamStatus.ahead
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'commit',
|
||||
labelIntent: 'commit',
|
||||
titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMobilePrimaryActionDuringRemoteOp(
|
||||
inputs: MobileSourceControlPrimaryActionDecisionInputs
|
||||
): MobileSourceControlPrimaryActionDecision {
|
||||
const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs
|
||||
const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
|
||||
...inputs,
|
||||
isRemoteOperationActive: false
|
||||
})
|
||||
const inFlightIsPrimaryKind =
|
||||
inFlightRemoteOpKind === 'push' ||
|
||||
inFlightRemoteOpKind === 'pull' ||
|
||||
inFlightRemoteOpKind === 'sync' ||
|
||||
inFlightRemoteOpKind === 'publish'
|
||||
|
||||
if (inFlightRemoteOpKind === 'force_push') {
|
||||
return {
|
||||
kind: 'push',
|
||||
labelIntent: 'force_push',
|
||||
titleIntent: 'force_push_in_progress',
|
||||
disabled: true,
|
||||
requiresForceWithLease: true
|
||||
}
|
||||
}
|
||||
|
||||
if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) {
|
||||
return {
|
||||
kind: inFlightRemoteOpKind,
|
||||
labelIntent: inFlightRemoteOpKind,
|
||||
titleIntent: 'action_in_progress',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
const titleIntent = hasUnresolvedConflicts
|
||||
? 'resolve_conflicts_before_commit'
|
||||
: candidate.kind === 'commit'
|
||||
? 'remote_operation_blocks_commit'
|
||||
: 'remote_operation_in_progress'
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
titleIntent,
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
function shouldForcePushWithLeaseForMobileUpstream(
|
||||
status: MobileGitUpstreamStatus | undefined
|
||||
): boolean {
|
||||
return (
|
||||
status?.hasUpstream === true &&
|
||||
status.ahead > 0 &&
|
||||
status.behind > 0 &&
|
||||
status.behindCommitsArePatchEquivalent === true
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision'
|
||||
import {
|
||||
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
|
||||
type MobileSourceControlPrimaryActionDecisionInputs
|
||||
} from './mobile-source-control-primary-action-decision'
|
||||
import {
|
||||
buildMobileSourceControlPrimaryAction,
|
||||
type MobileSourceControlPrimaryActionArgs,
|
||||
@@ -159,113 +154,3 @@ describe('buildMobileSourceControlPrimaryAction', () => {
|
||||
expect(action.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function decisionInputs(
|
||||
overrides: Partial<MobileSourceControlPrimaryActionDecisionInputs> = {}
|
||||
): MobileSourceControlPrimaryActionDecisionInputs {
|
||||
return {
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
isCommitting: false,
|
||||
isRemoteOperationActive: false,
|
||||
upstreamStatus: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile source-control primary action decision parity', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'dirty tree stages first',
|
||||
input: decisionInputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'staged message commits',
|
||||
input: decisionInputs({ stagedCount: 1, hasMessage: true })
|
||||
},
|
||||
{
|
||||
name: 'staged without message blocks commit',
|
||||
input: decisionInputs({ stagedCount: 1, hasMessage: false })
|
||||
},
|
||||
{
|
||||
name: 'unresolved conflicts block commit',
|
||||
input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true })
|
||||
},
|
||||
{
|
||||
name: 'unpublished branch publishes',
|
||||
input: decisionInputs({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
hasCurrentBranch: true
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'detached head blocks publish',
|
||||
input: decisionInputs({
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
|
||||
hasCurrentBranch: false
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'tracked ahead pushes',
|
||||
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } })
|
||||
},
|
||||
{
|
||||
name: 'tracked behind pulls',
|
||||
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } })
|
||||
},
|
||||
{
|
||||
name: 'tracked diverged syncs',
|
||||
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
|
||||
},
|
||||
{
|
||||
name: 'patch-equivalent diverged force-pushes with lease',
|
||||
input: decisionInputs({
|
||||
branchCommitsAhead: 4,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 10,
|
||||
behind: 2,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'in-flight pull mirrors pull',
|
||||
input: decisionInputs({
|
||||
isRemoteOperationActive: true,
|
||||
inFlightRemoteOpKind: 'pull',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'in-flight force push mirrors force push',
|
||||
input: decisionInputs({
|
||||
isRemoteOperationActive: true,
|
||||
inFlightRemoteOpKind: 'force_push',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'in-flight push blocks committable candidate',
|
||||
input: decisionInputs({
|
||||
isRemoteOperationActive: true,
|
||||
inFlightRemoteOpKind: 'push',
|
||||
stagedCount: 1,
|
||||
hasMessage: true
|
||||
})
|
||||
}
|
||||
])('matches the shared commit-area decision for $name', ({ input }) => {
|
||||
expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual(
|
||||
resolveSourceControlCommitAreaPrimaryActionDecision(input)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
|
||||
type MobileSourceControlPrimaryActionDecision,
|
||||
type MobileSourceControlPrimaryActionKind,
|
||||
type MobileSourceControlRemoteOpKind
|
||||
} from './mobile-source-control-primary-action-decision'
|
||||
resolveSourceControlCommitAreaPrimaryActionDecision,
|
||||
type SourceControlCommitAreaPrimaryActionDecision,
|
||||
type SourceControlRemoteOpKind
|
||||
} from '../../../src/shared/source-control-primary-action-decision'
|
||||
import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
|
||||
import type { MobileGitStatusResult } from './mobile-git-status'
|
||||
|
||||
type GitStep = { method: string; params?: Record<string, unknown> }
|
||||
|
||||
type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind']
|
||||
type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision
|
||||
type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind
|
||||
|
||||
export type MobileSourceControlPrimaryAction = {
|
||||
kind: MobileSourceControlPrimaryActionKind
|
||||
label: string
|
||||
@@ -44,7 +47,7 @@ export type MobileSourceControlPrimaryActionArgs = {
|
||||
export function buildMobileSourceControlPrimaryAction(
|
||||
args: MobileSourceControlPrimaryActionArgs
|
||||
): MobileSourceControlPrimaryAction {
|
||||
const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
|
||||
const decision = resolveSourceControlCommitAreaPrimaryActionDecision({
|
||||
stagedCount: args.stagedCount,
|
||||
hasUnstagedChanges: args.unstagedCount > 0,
|
||||
hasStageableChanges: args.stageablePaths.length > 0,
|
||||
@@ -167,8 +170,16 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe
|
||||
return 'Stage at least one file to commit.'
|
||||
case 'checkout_branch_before_publish':
|
||||
return 'Check out a branch before publishing commits.'
|
||||
case 'checking_review_status':
|
||||
return 'Checking review status.'
|
||||
case 'review_already_merged':
|
||||
return 'Nothing to commit. The review is already merged.'
|
||||
case 'publish_branch':
|
||||
return 'Publish this branch to origin.'
|
||||
case 'push_linked_review':
|
||||
return 'Push updates to the linked review branch.'
|
||||
case 'linked_review_target_unavailable':
|
||||
return 'The linked review branch is unavailable.'
|
||||
case 'force_push_with_lease':
|
||||
return 'Force push with lease to update the remote branch.'
|
||||
case 'sync_counts':
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canSubmitPrCompose,
|
||||
getPrComposeDisabledReason,
|
||||
isBaseHeadDistinct
|
||||
} from './pr-compose-validation'
|
||||
|
||||
describe('isBaseHeadDistinct', () => {
|
||||
it('is true when base and head differ', () => {
|
||||
expect(isBaseHeadDistinct('main', 'feature')).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when they match after normalization (case-insensitive, prefix-stripped)', () => {
|
||||
expect(isBaseHeadDistinct('main', 'main')).toBe(false)
|
||||
expect(isBaseHeadDistinct('refs/heads/main', 'main')).toBe(false)
|
||||
expect(isBaseHeadDistinct('origin/Main', 'main')).toBe(false)
|
||||
expect(isBaseHeadDistinct('upstream/main', 'refs/heads/main')).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for an empty base', () => {
|
||||
expect(isBaseHeadDistinct('', 'feature')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canSubmitPrCompose', () => {
|
||||
it('requires a non-empty title and a distinct base', () => {
|
||||
expect(canSubmitPrCompose('Title', 'main', 'feature')).toBe(true)
|
||||
expect(canSubmitPrCompose(' ', 'main', 'feature')).toBe(false)
|
||||
expect(canSubmitPrCompose('Title', 'main', 'main')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPrComposeDisabledReason', () => {
|
||||
it('returns null when the form can submit', () => {
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: 'Title',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
generating: false,
|
||||
reviewLabel: 'pull request'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('names the active blocker', () => {
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: 'Title',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
generating: true,
|
||||
reviewLabel: 'pull request'
|
||||
})
|
||||
).toBe('Wait for generation to finish.')
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: '',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
generating: false,
|
||||
reviewLabel: 'merge request'
|
||||
})
|
||||
).toBe('Enter a merge request title.')
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: 'Title',
|
||||
base: '',
|
||||
head: 'feature',
|
||||
generating: false,
|
||||
reviewLabel: 'pull request'
|
||||
})
|
||||
).toBe('Choose a base branch.')
|
||||
expect(
|
||||
getPrComposeDisabledReason({
|
||||
title: 'Title',
|
||||
base: 'main',
|
||||
head: 'main',
|
||||
generating: false,
|
||||
reviewLabel: 'pull request'
|
||||
})
|
||||
).toBe('Base branch must differ from the head branch.')
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
// Ref normalizers ported from src/shared/hosted-review-refs.ts (value-imported, not
|
||||
// referenced across the package boundary — Metro's resolver is rooted at mobile/ and
|
||||
// can't bundle a runtime import from repo-root/src, unlike erased `import type`s).
|
||||
// Keep in sync with the shared versions so mobile and desktop compare refs identically.
|
||||
function normalizeHeadRef(ref: string): string {
|
||||
return ref
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^refs\/remotes\/[^/]+\//, '')
|
||||
}
|
||||
|
||||
function normalizeBaseRef(ref: string): string {
|
||||
return normalizeHeadRef(ref).replace(/^(origin|upstream)\//, '')
|
||||
}
|
||||
|
||||
// Submit-gating for the create-PR composer, matching desktop CreatePullRequestDialog:
|
||||
// the base ref must be non-empty and must differ from the head branch after ref
|
||||
// normalization (strip refs/heads, remote prefixes, origin/upstream), case-insensitive.
|
||||
export function isBaseHeadDistinct(base: string, head: string): boolean {
|
||||
const b = normalizeBaseRef(base).toLowerCase()
|
||||
const h = normalizeHeadRef(head).toLowerCase()
|
||||
return b.length > 0 && b !== h
|
||||
}
|
||||
|
||||
export function canSubmitPrCompose(title: string, base: string, head: string): boolean {
|
||||
return title.trim().length > 0 && isBaseHeadDistinct(base, head)
|
||||
}
|
||||
|
||||
export function getPrComposeDisabledReason({
|
||||
title,
|
||||
base,
|
||||
head,
|
||||
generating,
|
||||
reviewLabel
|
||||
}: {
|
||||
title: string
|
||||
base: string
|
||||
head: string
|
||||
generating: boolean
|
||||
reviewLabel: string
|
||||
}): string | null {
|
||||
if (generating) {
|
||||
return 'Wait for generation to finish.'
|
||||
}
|
||||
if (title.trim().length === 0) {
|
||||
return `Enter a ${reviewLabel} title.`
|
||||
}
|
||||
if (base.trim().length === 0) {
|
||||
return 'Choose a base branch.'
|
||||
}
|
||||
if (!isBaseHeadDistinct(base, head)) {
|
||||
return 'Base branch must differ from the head branch.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
export type GitHubProjectSortDirection = 'ASC' | 'DESC'
|
||||
|
||||
export type GitHubProjectFieldValue =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'number'; number: number }
|
||||
| { kind: 'date'; date: string }
|
||||
| { kind: 'single-select'; optionId: string; name: string }
|
||||
| { kind: 'iteration'; iterationId: string; title: string }
|
||||
| { kind: 'labels'; labels: Array<{ name: string }> }
|
||||
| { kind: 'users'; users: Array<{ login: string }> }
|
||||
|
||||
export type GitHubProjectField = {
|
||||
id: string
|
||||
name: string
|
||||
kind: string
|
||||
options?: Array<{ id: string }>
|
||||
iterations?: Array<{
|
||||
id: string
|
||||
title?: string
|
||||
startDate: string
|
||||
duration: number
|
||||
completed: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export type GitHubProjectRow = {
|
||||
position?: number | null
|
||||
fieldValuesByFieldId: Record<string, GitHubProjectFieldValue>
|
||||
}
|
||||
|
||||
export type GitHubProjectSort = {
|
||||
direction: GitHubProjectSortDirection
|
||||
field: GitHubProjectField
|
||||
}
|
||||
|
||||
export type GitHubProjectTable = {
|
||||
selectedView: {
|
||||
groupByFields: GitHubProjectField[]
|
||||
sortByFields: GitHubProjectSort[]
|
||||
}
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
|
||||
export type ProjectGroup = {
|
||||
key: string
|
||||
label: string
|
||||
iteration: {
|
||||
startDate: string
|
||||
duration: number
|
||||
completed: boolean
|
||||
} | null
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
|
||||
const EMPTY_GROUP_KEY = '__empty__'
|
||||
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
|
||||
|
||||
function labelForEmpty(field: GitHubProjectField): string {
|
||||
return `No ${field.name}`
|
||||
}
|
||||
|
||||
function deriveStringValue(value: GitHubProjectFieldValue): string {
|
||||
switch (value.kind) {
|
||||
case 'text':
|
||||
return value.text
|
||||
case 'number':
|
||||
return String(value.number)
|
||||
case 'date':
|
||||
return value.date
|
||||
case 'single-select':
|
||||
return value.name
|
||||
case 'iteration':
|
||||
return value.title
|
||||
case 'labels':
|
||||
return value.labels.map((label) => label.name).join(', ')
|
||||
case 'users':
|
||||
return value.users.map((user) => user.login).join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldValueForGrouping(
|
||||
row: GitHubProjectRow,
|
||||
field: GitHubProjectField
|
||||
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
|
||||
const value = row.fieldValuesByFieldId[field.id]
|
||||
if (!value) {
|
||||
return {
|
||||
key: EMPTY_GROUP_KEY,
|
||||
label: labelForEmpty(field),
|
||||
orderHint: UNKNOWN_INDEX_SENTINEL,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'iteration' && value.kind === 'iteration') {
|
||||
const iterations = field.iterations ?? []
|
||||
const idx = iterations.findIndex((it) => it.id === value.iterationId)
|
||||
const meta = iterations.find((it) => it.id === value.iterationId)
|
||||
return {
|
||||
key: value.iterationId,
|
||||
label: value.title || meta?.title || 'Iteration',
|
||||
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
|
||||
iteration: meta
|
||||
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
|
||||
: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'single-select' && value.kind === 'single-select') {
|
||||
const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId)
|
||||
return {
|
||||
key: value.optionId,
|
||||
label: value.name,
|
||||
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
const label = deriveStringValue(value)
|
||||
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
|
||||
}
|
||||
|
||||
export function groupRows(
|
||||
table: GitHubProjectTable,
|
||||
rowsInOrder: GitHubProjectRow[]
|
||||
): ProjectGroup[] {
|
||||
const groupField = table.selectedView.groupByFields[0]
|
||||
if (!groupField) {
|
||||
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
|
||||
}
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
orderHint: number
|
||||
iteration: ProjectGroup['iteration']
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
>()
|
||||
for (const row of rowsInOrder) {
|
||||
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
|
||||
let bucket = buckets.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { label, orderHint, iteration, rows: [] }
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
bucket.rows.push(row)
|
||||
}
|
||||
const entries = Array.from(buckets.entries())
|
||||
entries.sort((a, b) => {
|
||||
if (a[0] === EMPTY_GROUP_KEY) {
|
||||
return 1
|
||||
}
|
||||
if (b[0] === EMPTY_GROUP_KEY) {
|
||||
return -1
|
||||
}
|
||||
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
|
||||
return a[1].orderHint - b[1].orderHint
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
return entries.map(([key, value]) => ({
|
||||
key,
|
||||
label: value.label,
|
||||
iteration: value.iteration,
|
||||
rows: value.rows
|
||||
}))
|
||||
}
|
||||
|
||||
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
|
||||
const field = sort.field
|
||||
const aValue = a.fieldValuesByFieldId[field.id]
|
||||
const bValue = b.fieldValuesByFieldId[field.id]
|
||||
if (!aValue && !bValue) {
|
||||
return 0
|
||||
}
|
||||
if (!aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!bValue) {
|
||||
return -1
|
||||
}
|
||||
|
||||
let cmp = 0
|
||||
if (
|
||||
field.kind === 'single-select' &&
|
||||
aValue.kind === 'single-select' &&
|
||||
bValue.kind === 'single-select'
|
||||
) {
|
||||
const options = field.options ?? []
|
||||
const aIdx = options.findIndex((option) => option.id === aValue.optionId)
|
||||
const bIdx = options.findIndex((option) => option.id === bValue.optionId)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (
|
||||
field.kind === 'iteration' &&
|
||||
aValue.kind === 'iteration' &&
|
||||
bValue.kind === 'iteration'
|
||||
) {
|
||||
const iterations = field.iterations ?? []
|
||||
const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId)
|
||||
const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
|
||||
cmp = aValue.number - bValue.number
|
||||
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
|
||||
cmp = aValue.date.localeCompare(bValue.date)
|
||||
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
|
||||
cmp = aValue.text.localeCompare(bValue.text)
|
||||
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
|
||||
const aLogin = aValue.users[0]?.login ?? ''
|
||||
const bLogin = bValue.users[0]?.login ?? ''
|
||||
if (!aLogin && !bLogin) {
|
||||
cmp = 0
|
||||
} else if (!aLogin) {
|
||||
cmp = 1
|
||||
} else if (!bLogin) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aLogin.localeCompare(bLogin)
|
||||
}
|
||||
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
|
||||
const aName = aValue.labels[0]?.name ?? ''
|
||||
const bName = bValue.labels[0]?.name ?? ''
|
||||
if (!aName && !bName) {
|
||||
cmp = 0
|
||||
} else if (!aName) {
|
||||
cmp = 1
|
||||
} else if (!bName) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aName.localeCompare(bName)
|
||||
}
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
return sort.direction === 'DESC' ? -cmp : cmp
|
||||
}
|
||||
|
||||
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
|
||||
const sorts = table.selectedView.sortByFields
|
||||
const out = [...rows]
|
||||
out.sort((a, b) => {
|
||||
for (const sort of sorts) {
|
||||
const cmp = compareSort(a, b, sort)
|
||||
if (cmp !== 0) {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
|
||||
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
|
||||
if (Number.isNaN(start)) {
|
||||
return false
|
||||
}
|
||||
const end = start + iteration.duration * 86_400_000
|
||||
const now = Date.now()
|
||||
return now >= start && now < end
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
|
||||
import {
|
||||
applyTerminalQuickCommandMutation,
|
||||
flattenTerminalQuickCommand,
|
||||
getTerminalQuickCommandBody,
|
||||
isTerminalAgentQuickCommand,
|
||||
MAX_QUICK_COMMANDS,
|
||||
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
|
||||
@@ -20,6 +21,7 @@ import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents'
|
||||
// — agent-branded labels, native row truncation, and the launch plan — live here.
|
||||
export {
|
||||
isTerminalAgentQuickCommand as isAgentQuickCommand,
|
||||
getTerminalQuickCommandBody,
|
||||
terminalQuickCommandMatchesRepo as quickCommandMatchesRepo,
|
||||
supportsTerminalAgentQuickCommand,
|
||||
MAX_QUICK_COMMANDS,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startPairingConnectionAttempt } from './pairing-connection-attempt'
|
||||
|
||||
describe('pairing connection attempt cleanup', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('closes the temporary client when the overall pairing timeout fires', () => {
|
||||
vi.useFakeTimers()
|
||||
const closeClient = vi.fn()
|
||||
|
||||
const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient })
|
||||
|
||||
expect(attempt.timedOut).toBe(false)
|
||||
vi.advanceTimersByTime(24_999)
|
||||
expect(closeClient).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(attempt.timedOut).toBe(true)
|
||||
expect(closeClient).toHaveBeenCalledTimes(1)
|
||||
|
||||
attempt.dispose()
|
||||
expect(closeClient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears the timeout and closes the temporary client when disposed early', () => {
|
||||
vi.useFakeTimers()
|
||||
const closeClient = vi.fn()
|
||||
|
||||
const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient })
|
||||
attempt.dispose()
|
||||
|
||||
expect(attempt.timedOut).toBe(false)
|
||||
expect(closeClient).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(25_000)
|
||||
expect(attempt.timedOut).toBe(false)
|
||||
expect(closeClient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
export type PairingConnectionAttempt = {
|
||||
readonly timedOut: boolean
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function startPairingConnectionAttempt({
|
||||
timeoutMs,
|
||||
closeClient
|
||||
}: {
|
||||
timeoutMs: number
|
||||
closeClient: () => void
|
||||
}): PairingConnectionAttempt {
|
||||
let disposed = false
|
||||
let clientClosed = false
|
||||
let timedOut = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function closeClientOnce() {
|
||||
if (clientClosed) {
|
||||
return
|
||||
}
|
||||
clientClosed = true
|
||||
closeClient()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
closeClientOnce()
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
timedOut = true
|
||||
dispose()
|
||||
}, timeoutMs)
|
||||
|
||||
return {
|
||||
get timedOut() {
|
||||
return timedOut
|
||||
},
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs
|
||||
item = record(node, len(records), path, window_rect)
|
||||
child_items = list(children(node))
|
||||
role_key = (item["controlType"] or "").lower()
|
||||
summary_values = text_snippets(node, limit=8, max_depth=4)
|
||||
generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None
|
||||
generic_summary = None
|
||||
if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]:
|
||||
summary_values = text_snippets(node, limit=8, max_depth=4)
|
||||
if len(summary_values) >= 2 and is_plain_text_subtree(node):
|
||||
generic_summary = " ".join(summary_values)
|
||||
if should_elide(item, len(child_items), generic_summary):
|
||||
for child_index, child in child_items:
|
||||
walk(child, depth, path + [child_index])
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FakeStateType:
|
||||
PROTECTED = 1
|
||||
SELECTED = 2
|
||||
|
||||
|
||||
class FakeText:
|
||||
@staticmethod
|
||||
def get_character_count(node):
|
||||
return len(node.value)
|
||||
|
||||
@staticmethod
|
||||
def get_text(node, start, end):
|
||||
return node.value[start:end]
|
||||
|
||||
|
||||
def load_runtime():
|
||||
gi = types.ModuleType("gi")
|
||||
repository = types.ModuleType("gi.repository")
|
||||
gi.require_version = lambda *_: None
|
||||
repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText)
|
||||
repository.Gdk = types.SimpleNamespace()
|
||||
repository.GdkPixbuf = types.SimpleNamespace()
|
||||
gi.repository = repository
|
||||
sys.modules["gi"] = gi
|
||||
sys.modules["gi.repository"] = repository
|
||||
|
||||
path = Path(__file__).with_name("runtime.py")
|
||||
spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
runtime = load_runtime()
|
||||
|
||||
|
||||
class FakeAccessible:
|
||||
def __init__(self, role, name="", value="", children=(), counter=None):
|
||||
self.role = role
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.children = list(children)
|
||||
self.counter = counter or {"child_reads": 0}
|
||||
for child in self.children:
|
||||
child.use_counter(self.counter)
|
||||
|
||||
def use_counter(self, counter):
|
||||
self.counter = counter
|
||||
for child in self.children:
|
||||
child.use_counter(counter)
|
||||
|
||||
def get_child_count(self):
|
||||
self.counter["child_reads"] += 1
|
||||
return len(self.children)
|
||||
|
||||
def get_child_at_index(self, index):
|
||||
self.counter["child_reads"] += 1
|
||||
return self.children[index]
|
||||
|
||||
def get_role_name(self):
|
||||
return self.role
|
||||
|
||||
def get_name(self):
|
||||
return self.name
|
||||
|
||||
def get_accessible_id(self):
|
||||
return ""
|
||||
|
||||
def get_toolkit_name(self):
|
||||
return "fake"
|
||||
|
||||
def get_component_iface(self):
|
||||
return None
|
||||
|
||||
def get_state_set(self):
|
||||
return None
|
||||
|
||||
def get_n_actions(self):
|
||||
return 0
|
||||
|
||||
def is_text(self):
|
||||
return bool(self.value)
|
||||
|
||||
def get_text_iface(self):
|
||||
return self if self.value else None
|
||||
|
||||
def get_value_iface(self):
|
||||
return None
|
||||
|
||||
|
||||
class FailingChildrenAccessible(FakeAccessible):
|
||||
def get_child_count(self):
|
||||
raise RuntimeError("defunct node")
|
||||
|
||||
|
||||
def render(root):
|
||||
return runtime.render_accessibility_tree(root, None, [0])
|
||||
|
||||
|
||||
class RuntimeRenderTest(unittest.TestCase):
|
||||
def test_named_non_generic_node_skips_unused_summary_walk(self):
|
||||
root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")])
|
||||
|
||||
records, lines, truncation = render(root)
|
||||
|
||||
self.assertEqual([record["name"] for record in records], ["Save"])
|
||||
self.assertEqual(lines, ["0 button Save"])
|
||||
self.assertEqual(root.counter["child_reads"], 2)
|
||||
self.assertFalse(truncation["truncated"])
|
||||
|
||||
def test_named_generic_node_skips_unused_summary_walk(self):
|
||||
root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")])
|
||||
|
||||
records, lines, _ = render(root)
|
||||
|
||||
self.assertEqual([record["name"] for record in records], ["Details", "body"])
|
||||
self.assertEqual(lines, ["0 section Details", "\t1 text body"])
|
||||
self.assertEqual(root.counter["child_reads"], 3)
|
||||
|
||||
def test_unnamed_generic_node_keeps_plain_text_summary(self):
|
||||
root = FakeAccessible(
|
||||
"section",
|
||||
children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")],
|
||||
)
|
||||
|
||||
records, lines, _ = render(root)
|
||||
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(lines, ["0 section, Text: Alpha Beta"])
|
||||
self.assertEqual(root.counter["child_reads"], 13)
|
||||
|
||||
def test_row_keeps_its_specific_summary_walk(self):
|
||||
root = FakeAccessible(
|
||||
"row",
|
||||
"Invoice",
|
||||
children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")],
|
||||
)
|
||||
|
||||
records, lines, _ = render(root)
|
||||
|
||||
self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"])
|
||||
self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"])
|
||||
self.assertEqual(root.counter["child_reads"], 10)
|
||||
|
||||
def test_empty_generic_wrapper_keeps_elision_path_and_depth(self):
|
||||
root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")])
|
||||
|
||||
records, lines, _ = render(root)
|
||||
|
||||
self.assertEqual([record["runtimeId"] for record in records], [[0, 0]])
|
||||
self.assertEqual(lines, ["0 button Continue"])
|
||||
|
||||
def test_child_failure_keeps_fail_soft_row_output(self):
|
||||
root = FailingChildrenAccessible("row", "Invoice")
|
||||
|
||||
records, lines, truncation = render(root)
|
||||
|
||||
self.assertEqual([record["name"] for record in records], ["Invoice"])
|
||||
self.assertEqual(lines, ["0 row Invoice"])
|
||||
self.assertFalse(truncation["truncated"])
|
||||
|
||||
def test_node_limit_keeps_exact_prefix_and_truncation(self):
|
||||
root = FakeAccessible(
|
||||
"document",
|
||||
"Results",
|
||||
children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)],
|
||||
)
|
||||
|
||||
records, _, truncation = render(root)
|
||||
|
||||
self.assertEqual(len(records), runtime.MAX_NODES)
|
||||
self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}")
|
||||
self.assertTrue(truncation["truncated"])
|
||||
self.assertFalse(truncation["maxDepthReached"])
|
||||
|
||||
def test_depth_limit_keeps_exact_prefix_and_flag(self):
|
||||
root = FakeAccessible("document", "Depth 65")
|
||||
for depth in range(runtime.MAX_DEPTH, -1, -1):
|
||||
root = FakeAccessible("document", f"Depth {depth}", children=[root])
|
||||
|
||||
records, _, truncation = render(root)
|
||||
|
||||
self.assertEqual(len(records), runtime.MAX_DEPTH + 1)
|
||||
self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}")
|
||||
self.assertTrue(truncation["truncated"])
|
||||
self.assertTrue(truncation["maxDepthReached"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,224 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Assert-TestEqual($Actual, $Expected, [string]$Label) {
|
||||
if ($Actual -ne $Expected) {
|
||||
throw "$Label expected '$Expected', received '$Actual'"
|
||||
}
|
||||
}
|
||||
|
||||
Add-Type -TypeDefinition @"
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public sealed class OrcaRenderTestCounter {
|
||||
public int FindAll;
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestBounds {
|
||||
public bool IsEmpty = true;
|
||||
public double X;
|
||||
public double Y;
|
||||
public double Width;
|
||||
public double Height;
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestControlType {
|
||||
public string ProgrammaticName = "";
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestCurrent {
|
||||
public string AutomationId = "";
|
||||
public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds();
|
||||
public string ClassName = "fake";
|
||||
public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType();
|
||||
public bool IsPassword;
|
||||
public string LocalizedControlType = "";
|
||||
public string Name = "";
|
||||
public long NativeWindowHandle;
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestPatternCurrent {
|
||||
public bool IsSelected;
|
||||
public string Value = "";
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestPattern {
|
||||
public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent();
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestCollection : IEnumerable {
|
||||
private readonly OrcaRenderTestElement[] values;
|
||||
|
||||
public OrcaRenderTestCollection(OrcaRenderTestElement[] values) {
|
||||
this.values = values;
|
||||
}
|
||||
|
||||
public int Count {
|
||||
get { return values.Length; }
|
||||
}
|
||||
|
||||
public OrcaRenderTestElement Item(int index) {
|
||||
return values[index];
|
||||
}
|
||||
|
||||
public IEnumerator GetEnumerator() {
|
||||
return values.GetEnumerator();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OrcaRenderTestElement {
|
||||
public readonly List<OrcaRenderTestElement> Children =
|
||||
new List<OrcaRenderTestElement>();
|
||||
public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter();
|
||||
public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent();
|
||||
public bool FailFindAll;
|
||||
public int RuntimeIdValue;
|
||||
public string ValueText = "";
|
||||
|
||||
public OrcaRenderTestCollection FindAll(object scope, object condition) {
|
||||
Counter.FindAll++;
|
||||
if (FailFindAll) {
|
||||
throw new System.InvalidOperationException("defunct node");
|
||||
}
|
||||
return new OrcaRenderTestCollection(Children.ToArray());
|
||||
}
|
||||
|
||||
public OrcaRenderTestPattern GetCurrentPattern(object pattern) {
|
||||
OrcaRenderTestPattern result = new OrcaRenderTestPattern();
|
||||
result.Current.Value = ValueText;
|
||||
return result;
|
||||
}
|
||||
|
||||
public int[] GetRuntimeId() {
|
||||
return new int[] { RuntimeIdValue };
|
||||
}
|
||||
|
||||
public object[] GetSupportedPatterns() {
|
||||
return new object[0];
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
function New-TestCounter {
|
||||
New-Object -TypeName OrcaRenderTestCounter
|
||||
}
|
||||
|
||||
function New-TestElement {
|
||||
param(
|
||||
[string]$Role,
|
||||
[string]$Name = "",
|
||||
[string]$Value = "",
|
||||
[object[]]$Children = @(),
|
||||
$Counter = $(New-TestCounter),
|
||||
[switch]$FailFindAll,
|
||||
[int]$RuntimeId = 1
|
||||
)
|
||||
$element = New-Object -TypeName OrcaRenderTestElement
|
||||
$element.Counter = $Counter
|
||||
$element.FailFindAll = [bool]$FailFindAll
|
||||
$element.RuntimeIdValue = $RuntimeId
|
||||
$element.ValueText = $Value
|
||||
$element.Current.ControlType.ProgrammaticName = $Role
|
||||
$element.Current.LocalizedControlType = $Role
|
||||
$element.Current.Name = $Name
|
||||
foreach ($child in @($Children)) {
|
||||
[void]$element.Children.Add($child)
|
||||
}
|
||||
$element
|
||||
}
|
||||
|
||||
$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json")
|
||||
try {
|
||||
Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}'
|
||||
$runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath
|
||||
$handshake = $runtimeOutput | ConvertFrom-Json
|
||||
Assert-TestEqual $handshake.ok $true "runtime handshake"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2
|
||||
$root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 1 "named control record count"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line"
|
||||
Assert-TestEqual $counter.findAll 1 "named control child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2
|
||||
$root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records"
|
||||
Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines"
|
||||
Assert-TestEqual $counter.findAll 2 "named generic child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2
|
||||
$beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3
|
||||
$root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary"
|
||||
Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2
|
||||
$beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3
|
||||
$root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary"
|
||||
Assert-TestEqual $counter.findAll 6 "row child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2
|
||||
$root = New-TestElement -Role "group" -Children @($button) -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line"
|
||||
Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration"
|
||||
|
||||
$counter = New-TestCounter
|
||||
$root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 1 "failed child read record count"
|
||||
Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line"
|
||||
Assert-TestEqual $counter.findAll 2 "failed child read retries"
|
||||
|
||||
$originalMaxNodes = $MaxNodes
|
||||
try {
|
||||
$MaxNodes = 3
|
||||
$counter = New-TestCounter
|
||||
$children = @()
|
||||
for ($index = 0; $index -lt 3; $index++) {
|
||||
$children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2)
|
||||
}
|
||||
$root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 3 "node limit record count"
|
||||
Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix"
|
||||
Assert-TestEqual $tree.truncation.truncated $true "node limit truncation"
|
||||
Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag"
|
||||
} finally {
|
||||
$MaxNodes = $originalMaxNodes
|
||||
}
|
||||
|
||||
$originalMaxDepth = $MaxDepth
|
||||
try {
|
||||
$MaxDepth = 2
|
||||
$counter = New-TestCounter
|
||||
$root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4
|
||||
for ($depth = 2; $depth -ge 0; $depth--) {
|
||||
$root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1)
|
||||
}
|
||||
$tree = Render-OrcaTree $root $null
|
||||
Assert-TestEqual $tree.elements.Count 3 "depth limit record count"
|
||||
Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix"
|
||||
Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation"
|
||||
Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag"
|
||||
} finally {
|
||||
$MaxDepth = $originalMaxDepth
|
||||
}
|
||||
|
||||
Write-Output "windows-snapshot-render-tests-ok"
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs =
|
||||
$title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name }
|
||||
$role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType }
|
||||
$roleKey = $role.ToLowerInvariant()
|
||||
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
|
||||
$genericSummary = $null
|
||||
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
|
||||
$genericSummary = ($snippets -join " ")
|
||||
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) {
|
||||
$snippets = @(Get-OrcaTextSnippets $Node 8 4)
|
||||
if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) {
|
||||
$genericSummary = ($snippets -join " ")
|
||||
}
|
||||
}
|
||||
if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) {
|
||||
for ($i = 0; $i -lt $children.Count; $i++) {
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
readHooksJsonWithRaw,
|
||||
wrapWindowsHookCommand,
|
||||
writeManagedScript,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
type HooksConfig
|
||||
} from './installer-utils'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
|
||||
|
||||
let tmpDir: string
|
||||
let configPath: string
|
||||
@@ -320,6 +320,53 @@ describe('removeManagedCommands', () => {
|
||||
|
||||
expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }])
|
||||
})
|
||||
|
||||
it('removes exec-form hooks whose managed script path is an argument', () => {
|
||||
const cleaned = removeManagedCommands(
|
||||
[
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'C:\\Windows\\System32\\conhost.exe',
|
||||
args: [
|
||||
'--headless',
|
||||
'C:\\Windows\\System32\\cmd.exe',
|
||||
'/d',
|
||||
'/c',
|
||||
'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd'
|
||||
]
|
||||
},
|
||||
{ type: 'command', command: 'echo keep me' }
|
||||
]
|
||||
}
|
||||
],
|
||||
match
|
||||
)
|
||||
|
||||
expect(cleaned).toEqual([{ hooks: [{ type: 'command', command: 'echo keep me' }] }])
|
||||
})
|
||||
|
||||
it('preserves user hooks with malformed args fields', () => {
|
||||
const definitions = [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command' as const,
|
||||
command: 'echo keep me',
|
||||
args: 'not-an-array' as unknown as string[]
|
||||
},
|
||||
{
|
||||
type: 'command' as const,
|
||||
command: 'echo keep me too',
|
||||
args: [42] as unknown as string[]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(removeManagedCommands(definitions, match)).toEqual(definitions)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hookDefinitionHasManagedCommand', () => {
|
||||
@@ -338,6 +385,34 @@ describe('hookDefinitionHasManagedCommand', () => {
|
||||
match
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hookDefinitionHasManagedCommand(
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'C:\\Windows\\System32\\conhost.exe',
|
||||
args: ['--headless', 'C:\\Users\\alice\\.orca\\agent-hooks\\copilot-hook.cmd']
|
||||
}
|
||||
]
|
||||
},
|
||||
match
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
hookDefinitionHasManagedCommand(
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'echo no',
|
||||
args: 'not-an-array' as unknown as string[]
|
||||
}
|
||||
]
|
||||
},
|
||||
match
|
||||
)
|
||||
).toBe(false)
|
||||
expect(hookDefinitionHasManagedCommand({ bash: 'echo no' }, match)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -573,28 +648,87 @@ describe('wrapWindowsCmdHookCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapWindowsGitBashHookCommand', () => {
|
||||
it('guards the forward-slash fast path and drains when missing', () => {
|
||||
expect(
|
||||
wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
).toBe(
|
||||
`if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
describe('wrapRuntimeHomeHookCommand', () => {
|
||||
it('selects the runtime platform variant under HOME', () => {
|
||||
const command = wrapRuntimeHomeHookCommand('claude-hook')
|
||||
|
||||
expect(command).toContain('case "${OSTYPE-}" in msys*|cygwin*|win32*)')
|
||||
expect(command).toContain('case "$HOME" in *\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*)')
|
||||
expect(command).not.toContain('uname')
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/claude-hook.cmd"')
|
||||
expect(command).toContain('/bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(command).not.toMatch(/[A-Z]:[\\/]|\/Users\/|\/home\//)
|
||||
})
|
||||
|
||||
it('rejects a script base name that could inject shell syntax', () => {
|
||||
expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow(
|
||||
'Invalid managed script base name'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when bash would split the path', () => {
|
||||
const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const command = wrapWindowsGitBashHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
|
||||
it('executes the destination HOME script for the current runtime', () => {
|
||||
const sourceHome = join(tmpDir, 'source profile')
|
||||
const destinationHome = join(tmpDir, "destination $HOME ' & profile")
|
||||
const sourceScriptDir = join(sourceHome, '.orca', 'agent-hooks')
|
||||
const destinationScriptDir = join(destinationHome, '.orca', 'agent-hooks')
|
||||
mkdirSync(sourceScriptDir, { recursive: true })
|
||||
mkdirSync(destinationScriptDir, { recursive: true })
|
||||
const windowsExitCode = process.platform === 'win32' ? 7 : 9
|
||||
const posixExitCode = process.platform === 'win32' ? 9 : 7
|
||||
writeFileSync(
|
||||
join(destinationScriptDir, 'claude-hook.cmd'),
|
||||
`@echo off\r\nexit /b ${windowsExitCode}\r\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(destinationScriptDir, 'claude-hook.sh'),
|
||||
`#!/bin/sh\nexit ${posixExitCode}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(join(sourceScriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 9\r\n', 'utf-8')
|
||||
writeFileSync(join(sourceScriptDir, 'claude-hook.sh'), '#!/bin/sh\nexit 9\n', 'utf-8')
|
||||
chmodSync(join(destinationScriptDir, 'claude-hook.sh'), 0o755)
|
||||
|
||||
const shell =
|
||||
process.platform === 'win32'
|
||||
? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
|
||||
: '/bin/sh'
|
||||
const result = spawnSync(shell, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], {
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: destinationHome.replaceAll('\\', '/'),
|
||||
USERPROFILE: destinationHome
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status, result.stderr.toString()).toBe(7)
|
||||
})
|
||||
|
||||
it('falls back to the encoded launcher when bash metacharacters are present', () => {
|
||||
const scriptPath = 'C:\\Users\\alice & bob\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const command = wrapWindowsGitBashHookCommand(scriptPath)
|
||||
expect(command).toMatch(qualifiedWindowsPowerShellCommand)
|
||||
expect(command).not.toContain('& bob')
|
||||
expect(decodeWindowsHookCommand(command)).toBe(expectedDecodedWindowsHookCommand(scriptPath))
|
||||
it.skipIf(process.platform !== 'win32')('keeps common Windows profiles on the fast path', () => {
|
||||
const destinationHome = join(tmpDir, 'destination 国際 profile')
|
||||
const scriptDir = join(destinationHome, '.orca', 'agent-hooks')
|
||||
mkdirSync(scriptDir, { recursive: true })
|
||||
writeFileSync(join(scriptDir, 'claude-hook.cmd'), '@echo off\r\nexit /b 7\r\n', 'utf-8')
|
||||
const gitBash = join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
|
||||
const result = spawnSync(gitBash, ['-c', wrapRuntimeHomeHookCommand('claude-hook')], {
|
||||
env: { ...process.env, HOME: destinationHome.replaceAll('\\', '/') }
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status, result.stderr.toString()).toBe(7)
|
||||
})
|
||||
|
||||
it('drains stdin when HOME is unavailable', () => {
|
||||
const command = `unset HOME; ${wrapRuntimeHomeHookCommand('claude-hook')}`
|
||||
const shell =
|
||||
process.platform === 'win32'
|
||||
? join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe')
|
||||
: '/bin/sh'
|
||||
const result = spawnSync(shell, ['-c', command], { input: Buffer.alloc(1_000_000, 'x') })
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.status).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { writeRollingFileBackup } from '../rolling-file-backup'
|
||||
export type HookCommandConfig = {
|
||||
type: 'command'
|
||||
command: string
|
||||
args?: string[]
|
||||
timeout?: number
|
||||
async?: boolean
|
||||
statusMessage?: string
|
||||
@@ -151,16 +152,6 @@ export function wrapWindowsCmdHookCommand(scriptPath: string): string {
|
||||
return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
|
||||
|
||||
export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
|
||||
const bashPath = scriptPath.replaceAll('\\', '/')
|
||||
// Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded.
|
||||
return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath)
|
||||
? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
: wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra form lines inserted before the final `payload@-` line (each should end with ` ^`).
|
||||
* Used by Grok to attach `grokHome` without fragile string replace on the shared template.
|
||||
@@ -215,7 +206,8 @@ export function removeManagedCommands(
|
||||
const directManagedKeys = directCommandKeys.filter((key) => isManagedCommand(definition[key]))
|
||||
const hasNestedHooks = Array.isArray(definition.hooks)
|
||||
const hasManagedNestedHook =
|
||||
hasNestedHooks && definition.hooks!.some((hook) => isManagedCommand(hook.command))
|
||||
hasNestedHooks &&
|
||||
definition.hooks!.some((hook) => hookHasManagedCommand(hook, isManagedCommand))
|
||||
|
||||
if (directManagedKeys.length === 0 && !hasManagedNestedHook) {
|
||||
return [definition]
|
||||
@@ -227,7 +219,9 @@ export function removeManagedCommands(
|
||||
}
|
||||
|
||||
if (hasManagedNestedHook) {
|
||||
const filteredHooks = definition.hooks!.filter((hook) => !isManagedCommand(hook.command))
|
||||
const filteredHooks = definition.hooks!.filter(
|
||||
(hook) => !hookHasManagedCommand(hook, isManagedCommand)
|
||||
)
|
||||
if (filteredHooks.length > 0) {
|
||||
nextDefinition.hooks = filteredHooks
|
||||
} else {
|
||||
@@ -246,6 +240,11 @@ export function removeManagedCommands(
|
||||
})
|
||||
}
|
||||
|
||||
function hookHasManagedCommand(hook: HookCommandConfig, matches: (value?: string) => boolean) {
|
||||
const args = Array.isArray(hook.args) ? hook.args : []
|
||||
return matches(hook.command) || args.some((arg) => typeof arg === 'string' && matches(arg))
|
||||
}
|
||||
|
||||
export function hookDefinitionHasManagedCommand(
|
||||
definition: HookDefinition,
|
||||
isManagedCommand: (command: string | undefined) => boolean
|
||||
@@ -255,7 +254,7 @@ export function hookDefinitionHasManagedCommand(
|
||||
isManagedCommand(definition.bash) ||
|
||||
isManagedCommand(definition.powershell) ||
|
||||
(Array.isArray(definition.hooks) &&
|
||||
definition.hooks.some((hook) => isManagedCommand(hook.command)))
|
||||
definition.hooks.some((hook) => hookHasManagedCommand(hook, isManagedCommand)))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,12 +79,9 @@ import { GeminiHookService } from '../gemini/hook-service'
|
||||
import { GrokHookService } from '../grok/hook-service'
|
||||
import { KimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import {
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
wrapWindowsHookCommand
|
||||
} from './installer-utils'
|
||||
import { wrapPosixHookCommand, wrapWindowsHookCommand } from './installer-utils'
|
||||
import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract'
|
||||
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
|
||||
import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture'
|
||||
|
||||
const REMOTE_HOME = '/home/dev'
|
||||
@@ -387,9 +384,9 @@ describe('Windows managed hook stdin structure', () => {
|
||||
args: ['/d', '/c', wrapWindowsHookCommand(missingScript)]
|
||||
},
|
||||
{
|
||||
name: 'Git Bash fast path',
|
||||
name: 'portable Git Bash launcher',
|
||||
executable: gitBash,
|
||||
args: ['-lc', wrapWindowsGitBashHookCommand(missingScript)]
|
||||
args: ['-lc', wrapRuntimeHomeHookCommand('missing-orca-hook')]
|
||||
}
|
||||
]
|
||||
for (const launcher of launcherCases) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
|
||||
const MANAGED_SCRIPT_BASE_NAME = /^[A-Za-z0-9_-]+$/
|
||||
const WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE = '*\\&*|*\\^*|*\\(*|*\\)*|*\\;*|*,*|*=*|*%*|*\\!*'
|
||||
|
||||
export function wrapRuntimeHomeHookCommand(scriptBaseName: string): string {
|
||||
if (!MANAGED_SCRIPT_BASE_NAME.test(scriptBaseName)) {
|
||||
throw new Error(`Invalid managed script base name: ${scriptBaseName}`)
|
||||
}
|
||||
const windowsScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.cmd"`
|
||||
const posixScript = `"$HOME/.orca/agent-hooks/${scriptBaseName}.sh"`
|
||||
const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND
|
||||
const powershell = '"$SYSTEMROOT/System32/WindowsPowerShell/v1.0/powershell.exe"'
|
||||
const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0`
|
||||
const encodedCommand = Buffer.from(powershellCommand, 'utf16le').toString('base64')
|
||||
const powershellInvocation = `${powershell} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
|
||||
const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${drain}; fi`
|
||||
const windowsBranch = `if [ -f ${windowsScript} ]; then case "$HOME" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${drain}; fi`
|
||||
const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${drain}; fi`
|
||||
// Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation.
|
||||
return `if [ -z "\${HOME-}" ]; then ${drain}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi`
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { isPackaged: false },
|
||||
safeStorage: { isEncryptionAvailable: () => false }
|
||||
}))
|
||||
|
||||
import { ArtifactCloudService } from './artifact-cloud-service'
|
||||
|
||||
const createdPaths: string[] = []
|
||||
const apiUrl = 'http://localhost:3000'
|
||||
const writeRequest = {
|
||||
sourceKey: '/repo/report.html',
|
||||
content: '<h1>Recovery</h1>',
|
||||
contentType: 'text/html' as const,
|
||||
fileName: 'report.html',
|
||||
apiUrl,
|
||||
authToken: 'token-a'
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
await Promise.all(
|
||||
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('ArtifactCloudService committed response loss recovery', () => {
|
||||
it('reconciles one remotely revocable artifact after a committed create loses its response', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const server = new ArtifactFaultServer()
|
||||
server.loseNextCreateResponse = true
|
||||
vi.stubGlobal('fetch', server.fetch)
|
||||
|
||||
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
|
||||
expect(server.createMutations).toBe(1)
|
||||
expect(server.artifactSlugs()).toEqual(['artifact-1'])
|
||||
await expect(publishedLink(userDataPath)).resolves.toBeNull()
|
||||
|
||||
await expect(
|
||||
service(userDataPath).publish({ ...writeRequest, content: '<h1>Changed after loss</h1>' })
|
||||
).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { item: { artifact: { slug: 'artifact-1' } } }
|
||||
})
|
||||
expect(server.createMutations).toBe(1)
|
||||
expect(server.artifactSlugs()).toEqual(['artifact-1'])
|
||||
expect(server.artifactContent('artifact-1')).toBe('<h1>Changed after loss</h1>')
|
||||
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
|
||||
})
|
||||
|
||||
it('replays the exact create when content is unchanged after response loss', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const server = new ArtifactFaultServer()
|
||||
server.loseNextCreateResponse = true
|
||||
vi.stubGlobal('fetch', server.fetch)
|
||||
|
||||
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
|
||||
await expect(service(userDataPath).publish(writeRequest)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { item: { artifact: { slug: 'artifact-1' } } }
|
||||
})
|
||||
expect(server.createMutations).toBe(1)
|
||||
expect(server.artifactContent('artifact-1')).toBe(writeRequest.content)
|
||||
})
|
||||
|
||||
it('updates a recovered share when its content changed after response loss', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const server = new ArtifactFaultServer()
|
||||
server.loseNextCreateResponse = true
|
||||
vi.stubGlobal('fetch', server.fetch)
|
||||
|
||||
await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost')
|
||||
await expect(
|
||||
service(userDataPath).share({ ...writeRequest, content: '<h1>Changed share</h1>' })
|
||||
).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { artifact: { slug: 'artifact-1' } }
|
||||
})
|
||||
expect(server.createMutations).toBe(1)
|
||||
expect(server.artifactContent('artifact-1')).toBe('<h1>Changed share</h1>')
|
||||
})
|
||||
|
||||
it('retains recovery until a changed-content update succeeds', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const server = new ArtifactFaultServer()
|
||||
server.loseNextCreateResponse = true
|
||||
vi.stubGlobal('fetch', server.fetch)
|
||||
|
||||
await expect(service(userDataPath).share(writeRequest)).rejects.toThrow('response lost')
|
||||
server.rejectNextUpdateStatus = 503
|
||||
const changed = { ...writeRequest, content: '<h1>Changed after update failure</h1>' }
|
||||
await expect(service(userDataPath).share(changed)).rejects.toMatchObject({ statusCode: 503 })
|
||||
await expect(service(userDataPath).share(changed)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { artifact: { slug: 'artifact-1' } }
|
||||
})
|
||||
expect(server.createMutations).toBe(1)
|
||||
expect(server.artifactSlugs()).toEqual(['artifact-1'])
|
||||
expect(server.artifactContent('artifact-1')).toBe(changed.content)
|
||||
})
|
||||
|
||||
it('clears the durable mapping when a committed delete retry returns 404', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const server = new ArtifactFaultServer()
|
||||
vi.stubGlobal('fetch', server.fetch)
|
||||
await service(userDataPath).publish(writeRequest)
|
||||
|
||||
server.loseNextDeleteResponse = true
|
||||
await expect(
|
||||
service(userDataPath).unshare({
|
||||
sourceKey: writeRequest.sourceKey,
|
||||
apiUrl,
|
||||
authToken: 'token-a'
|
||||
})
|
||||
).rejects.toThrow('response lost')
|
||||
expect(server.deleteMutations).toBe(1)
|
||||
expect(server.artifactSlugs()).toEqual([])
|
||||
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
|
||||
|
||||
await expect(
|
||||
service(userDataPath).unshare({
|
||||
sourceKey: writeRequest.sourceKey,
|
||||
apiUrl,
|
||||
authToken: 'token-a'
|
||||
})
|
||||
).resolves.toEqual({ status: 'ok', value: undefined })
|
||||
expect(server.deleteMutations).toBe(1)
|
||||
expect(server.artifactSlugs()).toEqual([])
|
||||
await expect(publishedLink(userDataPath)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the durable mapping when a delete receives an unrelated 404', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const server = new ArtifactFaultServer()
|
||||
vi.stubGlobal('fetch', server.fetch)
|
||||
await service(userDataPath).publish(writeRequest)
|
||||
|
||||
server.rejectNextDeleteCode = 'not_found'
|
||||
await expect(
|
||||
service(userDataPath).unshare({
|
||||
sourceKey: writeRequest.sourceKey,
|
||||
apiUrl,
|
||||
authToken: 'token-a'
|
||||
})
|
||||
).rejects.toMatchObject({ statusCode: 404, errorCode: 'not_found' })
|
||||
expect(server.deleteMutations).toBe(0)
|
||||
expect(server.artifactSlugs()).toEqual(['artifact-1'])
|
||||
await expect(publishedLink(userDataPath)).resolves.toBe('https://share.onorca.dev/a/artifact-1')
|
||||
})
|
||||
|
||||
it('drops an uncommitted validation failure so corrected content can create', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const server = new ArtifactFaultServer()
|
||||
server.rejectNextCreateStatus = 422
|
||||
vi.stubGlobal('fetch', server.fetch)
|
||||
|
||||
await expect(service(userDataPath).publish(writeRequest)).rejects.toMatchObject({
|
||||
statusCode: 422
|
||||
})
|
||||
await expect(
|
||||
service(userDataPath).publish({ ...writeRequest, content: '<h1>Corrected</h1>' })
|
||||
).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { item: { artifact: { slug: 'artifact-1' } } }
|
||||
})
|
||||
expect(server.createMutations).toBe(1)
|
||||
expect(server.artifactContent('artifact-1')).toBe('<h1>Corrected</h1>')
|
||||
})
|
||||
|
||||
it('keeps a replay intent when validation changes after the original commit', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const server = new ArtifactFaultServer()
|
||||
server.loseNextCreateResponse = true
|
||||
vi.stubGlobal('fetch', server.fetch)
|
||||
|
||||
await expect(service(userDataPath).publish(writeRequest)).rejects.toThrow('response lost')
|
||||
server.rejectNextCreateStatus = 422
|
||||
const changed = { ...writeRequest, content: '<h1>Changed after validation</h1>' }
|
||||
await expect(service(userDataPath).publish(changed)).rejects.toMatchObject({ statusCode: 422 })
|
||||
await expect(service(userDataPath).publish(changed)).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { item: { artifact: { slug: 'artifact-1' } } }
|
||||
})
|
||||
expect(server.createMutations).toBe(1)
|
||||
expect(server.artifactContent('artifact-1')).toBe(changed.content)
|
||||
})
|
||||
})
|
||||
|
||||
class ArtifactFaultServer {
|
||||
readonly fetch = vi.fn(this.handle.bind(this))
|
||||
createMutations = 0
|
||||
deleteMutations = 0
|
||||
loseNextCreateResponse = false
|
||||
loseNextDeleteResponse = false
|
||||
rejectNextCreateStatus: number | null = null
|
||||
rejectNextDeleteCode: string | null = null
|
||||
rejectNextUpdateStatus: number | null = null
|
||||
private readonly artifacts = new Map<string, string>()
|
||||
private readonly createsByKey = new Map<string, { body: string; response: object }>()
|
||||
|
||||
artifactSlugs(): string[] {
|
||||
return [...this.artifacts.keys()].sort()
|
||||
}
|
||||
|
||||
artifactContent(slug: string): string | undefined {
|
||||
const body = this.artifacts.get(slug)
|
||||
return body ? (JSON.parse(body) as { content?: string }).content : undefined
|
||||
}
|
||||
|
||||
private async handle(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'POST') {
|
||||
return this.create(init)
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
return this.update(String(input), init)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
return this.delete(String(input))
|
||||
}
|
||||
throw new Error(`Unexpected artifact request: ${method} ${String(input)}`)
|
||||
}
|
||||
|
||||
private create(init?: RequestInit): Response {
|
||||
if (this.rejectNextCreateStatus !== null) {
|
||||
const status = this.rejectNextCreateStatus
|
||||
this.rejectNextCreateStatus = null
|
||||
return jsonResponse({ code: 'artifact_validation_failed' }, status)
|
||||
}
|
||||
const key = new Headers(init?.headers).get('idempotency-key')
|
||||
if (new Headers(init?.headers).get('authorization') !== 'Bearer token-a') {
|
||||
throw new Error('Missing artifact authorization')
|
||||
}
|
||||
if (!key) {
|
||||
throw new Error('Missing idempotency key')
|
||||
}
|
||||
const body = String(init?.body)
|
||||
const existing = this.createsByKey.get(key)
|
||||
if (existing) {
|
||||
if (existing.body !== body) {
|
||||
throw new Error('Idempotency key reused with another request')
|
||||
}
|
||||
return jsonResponse(existing.response, 201)
|
||||
}
|
||||
|
||||
this.createMutations += 1
|
||||
const slug = `artifact-${this.createMutations}`
|
||||
const response = createResponseBody(slug)
|
||||
this.createsByKey.set(key, { body, response })
|
||||
this.artifacts.set(slug, body)
|
||||
if (this.loseNextCreateResponse) {
|
||||
this.loseNextCreateResponse = false
|
||||
throw new TypeError('response lost after committed create')
|
||||
}
|
||||
return jsonResponse(response, 201)
|
||||
}
|
||||
|
||||
private delete(url: string): Response {
|
||||
if (this.rejectNextDeleteCode !== null) {
|
||||
const code = this.rejectNextDeleteCode
|
||||
this.rejectNextDeleteCode = null
|
||||
return jsonResponse({ code }, 404)
|
||||
}
|
||||
const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1))
|
||||
if (!this.artifacts.delete(slug)) {
|
||||
return jsonResponse({ code: 'artifact_not_found' }, 404)
|
||||
}
|
||||
this.deleteMutations += 1
|
||||
if (this.loseNextDeleteResponse) {
|
||||
this.loseNextDeleteResponse = false
|
||||
throw new TypeError('response lost after committed delete')
|
||||
}
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
private update(url: string, init?: RequestInit): Response {
|
||||
if (this.rejectNextUpdateStatus !== null) {
|
||||
const status = this.rejectNextUpdateStatus
|
||||
this.rejectNextUpdateStatus = null
|
||||
return jsonResponse({ code: 'artifact_update_failed' }, status)
|
||||
}
|
||||
const slug = decodeURIComponent(url.slice(url.lastIndexOf('/') + 1))
|
||||
if (!this.artifacts.has(slug)) {
|
||||
return jsonResponse({ code: 'artifact_not_found' }, 404)
|
||||
}
|
||||
const headers = new Headers(init?.headers)
|
||||
if (
|
||||
headers.get('authorization') !== 'Bearer token-a' ||
|
||||
headers.get('x-orca-edit-token') !== `edit-${slug}`
|
||||
) {
|
||||
return jsonResponse({ code: 'artifact_forbidden' }, 403)
|
||||
}
|
||||
this.artifacts.set(slug, String(init?.body))
|
||||
return jsonResponse(createResponseBody(slug), 200)
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserDataPath(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-recovery-'))
|
||||
createdPaths.push(path)
|
||||
return path
|
||||
}
|
||||
|
||||
function service(userDataPath: string): ArtifactCloudService {
|
||||
return new ArtifactCloudService(userDataPath, () => true)
|
||||
}
|
||||
|
||||
async function publishedLink(userDataPath: string): Promise<string | null> {
|
||||
const result = await service(userDataPath).getPublishedLink({
|
||||
sourceKey: writeRequest.sourceKey,
|
||||
apiUrl,
|
||||
authToken: 'token-a'
|
||||
})
|
||||
return result.status === 'ok' ? (result.value?.shareUrl ?? null) : null
|
||||
}
|
||||
|
||||
function jsonResponse(body: object, status: number): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
function createResponseBody(slug: string): object {
|
||||
return {
|
||||
artifact: {
|
||||
version: 1,
|
||||
slug,
|
||||
title: null,
|
||||
originalFileName: 'report.html',
|
||||
sourceContentType: 'text/html',
|
||||
renderedContentType: 'text/html',
|
||||
createdAt: '2026-08-06T00:00:00.000Z',
|
||||
updatedAt: '2026-08-06T00:00:00.000Z',
|
||||
expiresAt: '2026-09-06T00:00:00.000Z',
|
||||
byteSize: 17,
|
||||
deletedAt: null
|
||||
},
|
||||
shareUrl: `https://share.onorca.dev/a/${slug}`,
|
||||
editToken: `edit-${slug}`
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ArtifactWriteRequest } from '../../shared/artifacts'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
|
||||
export function artifactWriteBody(request: ArtifactWriteRequest): Record<string, string> {
|
||||
export type ArtifactWriteBody = {
|
||||
content: string
|
||||
contentType: ArtifactWriteRequest['contentType']
|
||||
fileName: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function artifactWriteBody(request: ArtifactWriteRequest): ArtifactWriteBody {
|
||||
return {
|
||||
content: request.content,
|
||||
contentType: request.contentType,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { assertArtifactSharingAllowed } from '../../shared/artifact-sharing-gate'
|
||||
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
|
||||
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import { prepareArtifactCloudUse } from '../orca-profiles/profile-artifact-cloud-cleanup'
|
||||
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
|
||||
import {
|
||||
allowsArtifactCloudAuthOverride,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import type { ActiveOrcaProfileState } from '../orca-profiles/profile-index-store'
|
||||
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
|
||||
import { ArtifactPublisher } from './artifact-publisher'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
|
||||
type ArtifactAuthContext = {
|
||||
profileId: string
|
||||
@@ -35,6 +37,28 @@ type ArtifactAuthContext = {
|
||||
assertCurrent: () => void
|
||||
}
|
||||
|
||||
async function deleteArtifactRequest(
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
path: string,
|
||||
editToken?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await artifactRequest<void>(apiUrl, token, path, {
|
||||
method: 'DELETE',
|
||||
...(editToken ? { editToken } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof OrcaCloudRequestError) ||
|
||||
error.statusCode !== 404 ||
|
||||
error.errorCode !== 'artifact_not_found'
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tokenFingerprint(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
@@ -227,10 +251,8 @@ export class ArtifactCloudService {
|
||||
}
|
||||
return this.publisher.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
await artifactRequest<void>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'DELETE',
|
||||
editToken: record.editToken
|
||||
})
|
||||
await deleteArtifactRequest(apiUrl, token, `/${record.slug}`, record.editToken)
|
||||
auth.assertCurrent()
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, {
|
||||
sourceKey: request.sourceKey,
|
||||
slug: record.slug
|
||||
@@ -244,9 +266,8 @@ export class ArtifactCloudService {
|
||||
return this.withAuth(options, (token, apiUrl, auth) =>
|
||||
this.publisher.runForSlug(id, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
await artifactRequest<void>(apiUrl, token, `/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await deleteArtifactRequest(apiUrl, token, `/${encodeURIComponent(id)}`)
|
||||
auth.assertCurrent()
|
||||
removeArtifactShareRecords(auth.profileId, this.userDataPath, auth.scope, { slug: id })
|
||||
})
|
||||
)
|
||||
@@ -258,6 +279,7 @@ export class ArtifactCloudService {
|
||||
): Promise<ArtifactCloudOperation<T>> {
|
||||
const apiUrl = resolveArtifactCloudApiUrl(options.apiUrl)
|
||||
const active = ensureActiveOrcaProfile(this.userDataPath)
|
||||
prepareArtifactCloudUse(active.profile, this.userDataPath)
|
||||
if (options.authToken?.trim()) {
|
||||
if (!allowsArtifactCloudAuthOverride()) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm, stat, truncate, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES, artifactWriteRequestByteLength } from '../../shared/artifacts'
|
||||
import {
|
||||
MAX_ARTIFACT_CREATE_INTENT_BYTES,
|
||||
MAX_PENDING_ARTIFACT_CREATES,
|
||||
clearArtifactCreateIntents,
|
||||
getArtifactCreateIntent,
|
||||
getOrCreateArtifactCreateIntent,
|
||||
removeArtifactCreateIntent
|
||||
} from './artifact-create-intent-store'
|
||||
import type { ArtifactShareScope } from './artifact-share-record-store'
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: vi.fn(), execFileSync: vi.fn() }))
|
||||
|
||||
const createdPaths: string[] = []
|
||||
const scope: ArtifactShareScope = {
|
||||
cloudUserId: 'user-a',
|
||||
cloudProfileId: 'cloud-a',
|
||||
cloudOrganizationId: 'org-a',
|
||||
apiOrigin: 'https://share.onorca.dev'
|
||||
}
|
||||
const body = {
|
||||
content: '<h1>Original</h1>',
|
||||
contentType: 'text/html' as const,
|
||||
fileName: 'report.html',
|
||||
title: 'Original'
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('artifact create intent store', () => {
|
||||
it('retains the first key and exact request until the matching create completes', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const sourceKey = String.raw`C:\repo\report.html`
|
||||
const first = getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
sourceKey,
|
||||
scope,
|
||||
'key-a',
|
||||
body
|
||||
)
|
||||
const retry = getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
sourceKey,
|
||||
scope,
|
||||
'key-b',
|
||||
{ ...body, content: '<h1>Changed</h1>' }
|
||||
)
|
||||
|
||||
expect(first).toEqual(retry)
|
||||
expect(retry).toMatchObject({ idempotencyKey: 'key-a', body })
|
||||
removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-b')
|
||||
expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).not.toBeNull()
|
||||
removeArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a')
|
||||
expect(getArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['user', { cloudUserId: 'user-b' }],
|
||||
['profile', { cloudProfileId: 'cloud-b' }],
|
||||
['organization', { cloudOrganizationId: 'org-b' }],
|
||||
['API origin', { apiOrigin: 'http://localhost:3000' }]
|
||||
])('isolates recovery intent by %s', async (_name, changedScope) => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const sourceKey = '/repo/report.html'
|
||||
getOrCreateArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, 'key-a', body)
|
||||
|
||||
expect(
|
||||
getArtifactCreateIntent('local-profile', userDataPath, sourceKey, {
|
||||
...scope,
|
||||
...changedScope
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds unresolved payload storage without dropping an existing intent', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
for (let index = 0; index < MAX_PENDING_ARTIFACT_CREATES; index += 1) {
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
`/repo/report-${index}.html`,
|
||||
scope,
|
||||
`key-${index}`,
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/overflow.html',
|
||||
scope,
|
||||
'overflow-key',
|
||||
body
|
||||
)
|
||||
).toThrow(/waiting for recovery/)
|
||||
expect(
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report-0.html',
|
||||
scope,
|
||||
'replacement-key',
|
||||
{ ...body, content: 'replacement' }
|
||||
).idempotencyKey
|
||||
).toBe('key-0')
|
||||
})
|
||||
|
||||
it('clears pending content at the profile lifecycle boundary', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
body
|
||||
)
|
||||
|
||||
clearArtifactCreateIntents('local-profile', userDataPath)
|
||||
|
||||
expect(
|
||||
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
|
||||
).toBeNull()
|
||||
expect(await readdir(directory)).toEqual([])
|
||||
})
|
||||
|
||||
it('removes crash-left temporary writes before admitting another intent', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
body
|
||||
)
|
||||
removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a')
|
||||
await writeFile(join(directory, 'crash-left.tmp'), 'partial')
|
||||
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/other.html',
|
||||
scope,
|
||||
'key-b',
|
||||
body
|
||||
)
|
||||
|
||||
expect((await readdir(directory)).some((name) => name.endsWith('.tmp'))).toBe(false)
|
||||
})
|
||||
|
||||
it('hardens one Windows journal directory without per-file PowerShell launches', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.mocked(execFileSync).mockImplementation((file) =>
|
||||
String(file).endsWith('whoami.exe') ? '"USER","S-1-5-21-1000"' : ''
|
||||
)
|
||||
try {
|
||||
const userDataPath = await createUserDataPath()
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
body
|
||||
)
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/other.html',
|
||||
scope,
|
||||
'key-b',
|
||||
body
|
||||
)
|
||||
|
||||
const powershellCalls = vi
|
||||
.mocked(execFileSync)
|
||||
.mock.calls.filter(([file]) => String(file).endsWith('powershell.exe'))
|
||||
expect(powershellCalls).toHaveLength(1)
|
||||
expect((powershellCalls[0]![1] as string[]).at(-1)).toBe('1')
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
vi.mocked(execFileSync).mockReset()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to overwrite an unreadable matching intent', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
body
|
||||
)
|
||||
const [fileName] = await readdir(directory)
|
||||
await writeFile(join(directory, fileName), '{broken-json')
|
||||
|
||||
expect(() =>
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-b',
|
||||
body
|
||||
)
|
||||
).toThrow(/could not be read safely/)
|
||||
})
|
||||
|
||||
it('removes an unreadable intent after its mutation completes', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
body
|
||||
)
|
||||
const [fileName] = await readdir(directory)
|
||||
await writeFile(join(directory, fileName), '{broken-json')
|
||||
|
||||
expect(() =>
|
||||
removeArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope, 'key-a')
|
||||
).not.toThrow()
|
||||
expect(await readdir(directory)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a persisted content type outside the artifact allowlist', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
body
|
||||
)
|
||||
const [fileName] = await readdir(directory)
|
||||
const path = join(directory, fileName)
|
||||
const intent = JSON.parse(await readFile(path, 'utf8')) as { body: { contentType: string } }
|
||||
intent.body.contentType = 'application/octet-stream'
|
||||
await writeFile(path, JSON.stringify(intent))
|
||||
|
||||
expect(() =>
|
||||
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
|
||||
).toThrow(/unsupported format/)
|
||||
})
|
||||
|
||||
it('persists a valid artifact request near the RPC limit', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const nearLimitBody = { ...body, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES - 200) }
|
||||
expect(
|
||||
artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody })
|
||||
).toBeLessThanOrEqual(ARTIFACT_CLI_MAX_RPC_BYTES)
|
||||
|
||||
expect(() =>
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
nearLimitBody
|
||||
)
|
||||
).not.toThrow()
|
||||
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
|
||||
const [fileName] = await readdir(directory)
|
||||
expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_CLI_MAX_RPC_BYTES)
|
||||
})
|
||||
|
||||
it('rejects an oversized recovery record before reading it', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
body
|
||||
)
|
||||
const [fileName] = await readdir(directory)
|
||||
await truncate(join(directory, fileName), MAX_ARTIFACT_CREATE_INTENT_BYTES + 1)
|
||||
|
||||
expect(() =>
|
||||
getArtifactCreateIntent('local-profile', userDataPath, '/repo/report.html', scope)
|
||||
).toThrow(/exceeds the supported size/)
|
||||
})
|
||||
})
|
||||
|
||||
async function createUserDataPath(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), 'orca-artifact-create-intents-'))
|
||||
createdPaths.push(path)
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
|
||||
import {
|
||||
bestEffortFsyncDirectorySync,
|
||||
fsyncFileSync,
|
||||
hardenSecurePath
|
||||
} from '../../shared/secure-file'
|
||||
import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths'
|
||||
import type { ArtifactWriteBody } from './artifact-cloud-request'
|
||||
import type { ArtifactShareScope } from './artifact-share-record-store'
|
||||
|
||||
export const MAX_PENDING_ARTIFACT_CREATES = 32
|
||||
export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_CLI_MAX_RPC_BYTES + 128 * 1024
|
||||
|
||||
const MAX_HARDENED_INTENT_DIRECTORIES = 64
|
||||
const hardenedIntentDirectories = new Set<string>()
|
||||
|
||||
export type ArtifactCreateIntent = {
|
||||
version: 1
|
||||
sourceKey: string
|
||||
scope: ArtifactShareScope
|
||||
idempotencyKey: string
|
||||
body: ArtifactWriteBody
|
||||
}
|
||||
|
||||
function intentDirectory(profileId: string, userDataPath: string): string {
|
||||
return join(getOrcaProfileDirectory(profileId, userDataPath), 'artifact-create-intents')
|
||||
}
|
||||
|
||||
function ensureIntentDirectory(profileId: string, userDataPath: string): string {
|
||||
const directory = intentDirectory(profileId, userDataPath)
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
if (!hardenedIntentDirectories.has(directory)) {
|
||||
hardenSecurePath(directory, {
|
||||
isDirectory: true,
|
||||
platform: process.platform,
|
||||
sync: true
|
||||
})
|
||||
if (hardenedIntentDirectories.size >= MAX_HARDENED_INTENT_DIRECTORIES) {
|
||||
const oldest = hardenedIntentDirectories.values().next().value
|
||||
if (oldest !== undefined) {
|
||||
hardenedIntentDirectories.delete(oldest)
|
||||
}
|
||||
}
|
||||
hardenedIntentDirectories.add(directory)
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
function removeTemporaryIntents(directory: string): void {
|
||||
for (const name of readdirSync(directory)) {
|
||||
if (name.endsWith('.tmp')) {
|
||||
rmSync(join(directory, name), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeIntent(path: string, directory: string, serializedIntent: string): void {
|
||||
// Child files inherit the once-hardened directory ACL, avoiding per-create PowerShell launches.
|
||||
const temporaryPath = `${path}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`
|
||||
try {
|
||||
writeFileSync(temporaryPath, serializedIntent, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600
|
||||
})
|
||||
fsyncFileSync(temporaryPath)
|
||||
renameSync(temporaryPath, path)
|
||||
bestEffortFsyncDirectorySync(directory)
|
||||
} catch (error) {
|
||||
rmSync(temporaryPath, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function intentPath(
|
||||
profileId: string,
|
||||
userDataPath: string,
|
||||
sourceKey: string,
|
||||
scope: ArtifactShareScope
|
||||
): string {
|
||||
const identity = JSON.stringify([
|
||||
sourceKey,
|
||||
scope.cloudUserId,
|
||||
scope.cloudProfileId,
|
||||
scope.cloudOrganizationId,
|
||||
scope.apiOrigin
|
||||
])
|
||||
const fileName = `${createHash('sha256').update(identity).digest('hex')}.json`
|
||||
return join(intentDirectory(profileId, userDataPath), fileName)
|
||||
}
|
||||
|
||||
function scopeMatches(left: ArtifactShareScope, right: ArtifactShareScope): boolean {
|
||||
return (
|
||||
left.cloudUserId === right.cloudUserId &&
|
||||
left.cloudProfileId === right.cloudProfileId &&
|
||||
left.cloudOrganizationId === right.cloudOrganizationId &&
|
||||
left.apiOrigin === right.apiOrigin
|
||||
)
|
||||
}
|
||||
|
||||
function isWriteBody(value: unknown): value is ArtifactWriteBody {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const body = value as Partial<ArtifactWriteBody>
|
||||
return (
|
||||
typeof body.content === 'string' &&
|
||||
(body.contentType === 'text/html' || body.contentType === 'text/markdown') &&
|
||||
typeof body.fileName === 'string' &&
|
||||
(body.title === undefined || typeof body.title === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isScope(value: unknown): value is ArtifactShareScope {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const scope = value as Partial<ArtifactShareScope>
|
||||
return [
|
||||
scope.cloudUserId,
|
||||
scope.cloudProfileId,
|
||||
scope.cloudOrganizationId,
|
||||
scope.apiOrigin
|
||||
].every((field) => typeof field === 'string')
|
||||
}
|
||||
|
||||
function readIntent(path: string): ArtifactCreateIntent {
|
||||
let size: number
|
||||
try {
|
||||
size = statSync(path).size
|
||||
} catch (error) {
|
||||
throw new Error('Artifact create recovery record could not be read safely.', { cause: error })
|
||||
}
|
||||
if (size > MAX_ARTIFACT_CREATE_INTENT_BYTES) {
|
||||
throw new Error('Artifact create recovery record exceeds the supported size.')
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error('Artifact create recovery record could not be read safely.', { cause: error })
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Artifact create recovery record has an unsupported format.')
|
||||
}
|
||||
const intent = parsed as Partial<ArtifactCreateIntent>
|
||||
if (
|
||||
intent.version !== 1 ||
|
||||
typeof intent.sourceKey !== 'string' ||
|
||||
typeof intent.idempotencyKey !== 'string' ||
|
||||
!intent.idempotencyKey ||
|
||||
!isScope(intent.scope) ||
|
||||
!isWriteBody(intent.body)
|
||||
) {
|
||||
throw new Error('Artifact create recovery record has an unsupported format.')
|
||||
}
|
||||
return intent as ArtifactCreateIntent
|
||||
}
|
||||
|
||||
export function getArtifactCreateIntent(
|
||||
profileId: string,
|
||||
userDataPath: string,
|
||||
sourceKey: string,
|
||||
scope: ArtifactShareScope
|
||||
): ArtifactCreateIntent | null {
|
||||
const path = intentPath(profileId, userDataPath, sourceKey, scope)
|
||||
if (!existsSync(path)) {
|
||||
return null
|
||||
}
|
||||
const intent = readIntent(path)
|
||||
if (intent.sourceKey !== sourceKey || !scopeMatches(intent.scope, scope)) {
|
||||
throw new Error('Artifact create recovery record does not match its storage identity.')
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
export function getOrCreateArtifactCreateIntent(
|
||||
profileId: string,
|
||||
userDataPath: string,
|
||||
sourceKey: string,
|
||||
scope: ArtifactShareScope,
|
||||
idempotencyKey: string,
|
||||
body: ArtifactWriteBody
|
||||
): ArtifactCreateIntent {
|
||||
const existing = getArtifactCreateIntent(profileId, userDataPath, sourceKey, scope)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const directory = ensureIntentDirectory(profileId, userDataPath)
|
||||
removeTemporaryIntents(directory)
|
||||
const pendingCount = readdirSync(directory).filter((name) => name.endsWith('.json')).length
|
||||
if (pendingCount >= MAX_PENDING_ARTIFACT_CREATES) {
|
||||
throw new Error('Too many artifact creates are waiting for recovery. Retry an earlier share.')
|
||||
}
|
||||
const intent: ArtifactCreateIntent = {
|
||||
version: 1,
|
||||
sourceKey,
|
||||
scope,
|
||||
idempotencyKey,
|
||||
body
|
||||
}
|
||||
const serializedIntent = JSON.stringify(intent, null, 2)
|
||||
if (Buffer.byteLength(serializedIntent, 'utf8') > MAX_ARTIFACT_CREATE_INTENT_BYTES) {
|
||||
throw new Error('Artifact create recovery record exceeds the supported size.')
|
||||
}
|
||||
writeIntent(intentPath(profileId, userDataPath, sourceKey, scope), directory, serializedIntent)
|
||||
return intent
|
||||
}
|
||||
|
||||
export function removeArtifactCreateIntent(
|
||||
profileId: string,
|
||||
userDataPath: string,
|
||||
sourceKey: string,
|
||||
scope: ArtifactShareScope,
|
||||
expectedIdempotencyKey: string
|
||||
): void {
|
||||
const path = intentPath(profileId, userDataPath, sourceKey, scope)
|
||||
if (!existsSync(path)) {
|
||||
return
|
||||
}
|
||||
let matches = true
|
||||
try {
|
||||
matches = readIntent(path).idempotencyKey === expectedIdempotencyKey
|
||||
} catch {
|
||||
// An unreadable record cannot be replayed, so a completed mutation may discard it safely.
|
||||
}
|
||||
if (matches) {
|
||||
rmSync(path, { force: true })
|
||||
bestEffortFsyncDirectorySync(intentDirectory(profileId, userDataPath))
|
||||
}
|
||||
}
|
||||
|
||||
export function clearArtifactCreateIntents(profileId: string, userDataPath: string): void {
|
||||
const directory = intentDirectory(profileId, userDataPath)
|
||||
if (!existsSync(directory)) {
|
||||
return
|
||||
}
|
||||
for (const name of readdirSync(directory)) {
|
||||
if (name.endsWith('.json') || name.endsWith('.tmp')) {
|
||||
rmSync(join(directory, name), { force: true })
|
||||
}
|
||||
}
|
||||
bestEffortFsyncDirectorySync(directory)
|
||||
}
|
||||
@@ -4,7 +4,17 @@ import type {
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
import { OrcaCloudRequestError } from '../orca-profiles/profile-cloud-client'
|
||||
import { artifactRequest, artifactWriteBody } from './artifact-cloud-request'
|
||||
import {
|
||||
artifactRequest,
|
||||
type ArtifactWriteBody,
|
||||
artifactWriteBody
|
||||
} from './artifact-cloud-request'
|
||||
import {
|
||||
type ArtifactCreateIntent,
|
||||
getArtifactCreateIntent,
|
||||
getOrCreateArtifactCreateIntent,
|
||||
removeArtifactCreateIntent
|
||||
} from './artifact-create-intent-store'
|
||||
import {
|
||||
type ArtifactShareScope,
|
||||
getArtifactShareRecord,
|
||||
@@ -15,6 +25,30 @@ import {
|
||||
|
||||
type ArtifactCreateResponse = ArtifactListItem & { editToken: string }
|
||||
|
||||
type ArtifactCreateOutcome = {
|
||||
editToken: string
|
||||
intent: ArtifactCreateIntent
|
||||
result: ArtifactPublishResult
|
||||
}
|
||||
|
||||
function artifactWriteBodiesMatch(left: ArtifactWriteBody, right: ArtifactWriteBody): boolean {
|
||||
return (
|
||||
left.content === right.content &&
|
||||
left.contentType === right.contentType &&
|
||||
left.fileName === right.fileName &&
|
||||
left.title === right.title
|
||||
)
|
||||
}
|
||||
|
||||
function discardsCreateIntent(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof OrcaCloudRequestError &&
|
||||
error.statusCode >= 400 &&
|
||||
error.statusCode < 500 &&
|
||||
![408, 409, 425, 429].includes(error.statusCode)
|
||||
)
|
||||
}
|
||||
|
||||
type ArtifactPublishAuthContext = {
|
||||
profileId: string
|
||||
scope: ArtifactShareScope
|
||||
@@ -51,7 +85,23 @@ export class ArtifactPublisher {
|
||||
): Promise<ArtifactListItem> {
|
||||
return this.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
return (await this.create(request, token, apiUrl, auth, idempotencyKey)).item
|
||||
const pending = getArtifactCreateIntent(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending)
|
||||
if (pending && !artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) {
|
||||
const item = await this.updateExisting(request, token, apiUrl, auth, {
|
||||
slug: created.result.item.artifact.slug,
|
||||
editToken: created.editToken
|
||||
})
|
||||
this.removeCreateIntent(request, auth, created.intent)
|
||||
return item
|
||||
}
|
||||
this.removeCreateIntent(request, auth, created.intent)
|
||||
return created.result.item
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,6 +114,25 @@ export class ArtifactPublisher {
|
||||
): Promise<ArtifactPublishResult> {
|
||||
return this.runForSource(request.sourceKey, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const pending = getArtifactCreateIntent(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope
|
||||
)
|
||||
if (pending) {
|
||||
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, pending)
|
||||
if (artifactWriteBodiesMatch(pending.body, artifactWriteBody(request))) {
|
||||
this.removeCreateIntent(request, auth, created.intent)
|
||||
return created.result
|
||||
}
|
||||
const item = await this.updateExisting(request, token, apiUrl, auth, {
|
||||
slug: created.result.item.artifact.slug,
|
||||
editToken: created.editToken
|
||||
})
|
||||
this.removeCreateIntent(request, auth, created.intent)
|
||||
return { change: 'created', item }
|
||||
}
|
||||
const record = getArtifactShareRecord(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
@@ -72,24 +141,8 @@ export class ArtifactPublisher {
|
||||
)
|
||||
if (record) {
|
||||
try {
|
||||
return await this.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const item = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'PUT',
|
||||
editToken: record.editToken,
|
||||
body: artifactWriteBody(request)
|
||||
})
|
||||
auth.assertCurrent()
|
||||
refreshArtifactShareRecordExpiration(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
record,
|
||||
item.artifact.expiresAt
|
||||
)
|
||||
return { change: 'updated', item }
|
||||
})
|
||||
const item = await this.updateExisting(request, token, apiUrl, auth, record)
|
||||
return { change: 'updated', item }
|
||||
} catch (error) {
|
||||
if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 404) {
|
||||
throw error
|
||||
@@ -101,7 +154,9 @@ export class ArtifactPublisher {
|
||||
})
|
||||
}
|
||||
}
|
||||
return this.create(request, token, apiUrl, auth, idempotencyKey)
|
||||
const created = await this.create(request, token, apiUrl, auth, idempotencyKey, null)
|
||||
this.removeCreateIntent(request, auth, created.intent)
|
||||
return created.result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,18 +176,72 @@ export class ArtifactPublisher {
|
||||
return this.runSerialized(artifactOperationQueueKey('slug', auth, slug), operation)
|
||||
}
|
||||
|
||||
private updateExisting(
|
||||
request: ArtifactWriteRequest,
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
record: { slug: string; editToken: string }
|
||||
): Promise<ArtifactListItem> {
|
||||
return this.runForSlug(record.slug, auth, async () => {
|
||||
auth.assertCurrent()
|
||||
const item = await artifactRequest<ArtifactListItem>(apiUrl, token, `/${record.slug}`, {
|
||||
method: 'PUT',
|
||||
editToken: record.editToken,
|
||||
body: artifactWriteBody(request)
|
||||
})
|
||||
auth.assertCurrent()
|
||||
refreshArtifactShareRecordExpiration(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
record,
|
||||
item.artifact.expiresAt
|
||||
)
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
private async create(
|
||||
request: ArtifactWriteRequest,
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
idempotencyKey: string
|
||||
): Promise<ArtifactPublishResult> {
|
||||
const response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
|
||||
method: 'POST',
|
||||
body: artifactWriteBody(request),
|
||||
idempotencyKey
|
||||
})
|
||||
idempotencyKey: string,
|
||||
pending: ArtifactCreateIntent | null
|
||||
): Promise<ArtifactCreateOutcome> {
|
||||
const replaying = pending !== null
|
||||
const intent =
|
||||
pending ??
|
||||
getOrCreateArtifactCreateIntent(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
idempotencyKey,
|
||||
artifactWriteBody(request)
|
||||
)
|
||||
let response: ArtifactCreateResponse
|
||||
try {
|
||||
response = await artifactRequest<ArtifactCreateResponse>(apiUrl, token, '', {
|
||||
method: 'POST',
|
||||
body: intent.body,
|
||||
idempotencyKey: intent.idempotencyKey
|
||||
})
|
||||
} catch (error) {
|
||||
// A replay may outlive its server receipt, so never abandon a possibly committed artifact.
|
||||
if (!replaying && discardsCreateIntent(error)) {
|
||||
removeArtifactCreateIntent(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
intent.idempotencyKey
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
auth.assertCurrent()
|
||||
saveArtifactShareRecord(auth.profileId, this.userDataPath, request.sourceKey, {
|
||||
slug: response.artifact.slug,
|
||||
@@ -142,11 +251,29 @@ export class ArtifactPublisher {
|
||||
...auth.scope
|
||||
})
|
||||
return {
|
||||
change: 'created',
|
||||
item: { artifact: response.artifact, shareUrl: response.shareUrl }
|
||||
editToken: response.editToken,
|
||||
intent,
|
||||
result: {
|
||||
change: 'created',
|
||||
item: { artifact: response.artifact, shareUrl: response.shareUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeCreateIntent(
|
||||
request: ArtifactWriteRequest,
|
||||
auth: ArtifactPublishAuthContext,
|
||||
intent: ArtifactCreateIntent
|
||||
): void {
|
||||
removeArtifactCreateIntent(
|
||||
auth.profileId,
|
||||
this.userDataPath,
|
||||
request.sourceKey,
|
||||
auth.scope,
|
||||
intent.idempotencyKey
|
||||
)
|
||||
}
|
||||
|
||||
private async runSerialized<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queues.get(key) ?? Promise.resolve()
|
||||
let release = (): void => {}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const fsyncMockState = vi.hoisted(() => ({
|
||||
directoryDescriptor: -1,
|
||||
directoryErrorCode: 'EINVAL'
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
closeSync: (descriptor: number) => {
|
||||
if (descriptor !== fsyncMockState.directoryDescriptor) {
|
||||
actual.closeSync(descriptor)
|
||||
}
|
||||
},
|
||||
fsyncSync: (descriptor: number) => {
|
||||
if (descriptor === fsyncMockState.directoryDescriptor) {
|
||||
throw Object.assign(new Error('directory fsync failed'), {
|
||||
code: fsyncMockState.directoryErrorCode
|
||||
})
|
||||
}
|
||||
return actual.fsyncSync(descriptor)
|
||||
},
|
||||
openSync: (path: string, flags: string | number) =>
|
||||
actual.statSync(path).isDirectory()
|
||||
? fsyncMockState.directoryDescriptor
|
||||
: actual.openSync(path, flags)
|
||||
}
|
||||
})
|
||||
|
||||
import { bestEffortFsyncDirectorySync, writeDurableSecureJsonFile } from '../../shared/secure-file'
|
||||
import {
|
||||
clearArtifactCreateIntents,
|
||||
getOrCreateArtifactCreateIntent
|
||||
} from './artifact-create-intent-store'
|
||||
|
||||
const createdPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
fsyncMockState.directoryErrorCode = 'EINVAL'
|
||||
for (const path of createdPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips directory fsync on Windows and propagates I/O failures elsewhere', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-eio-'))
|
||||
createdPaths.push(directory)
|
||||
fsyncMockState.directoryErrorCode = 'EIO'
|
||||
|
||||
const fsyncDirectory = (): void => bestEffortFsyncDirectorySync(directory)
|
||||
if (process.platform === 'win32') {
|
||||
expect(fsyncDirectory).not.toThrow()
|
||||
} else {
|
||||
expect(fsyncDirectory).toThrow('directory fsync failed')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps durable artifact records usable when directory fsync is unsupported', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-artifact-directory-fsync-'))
|
||||
createdPaths.push(userDataPath)
|
||||
const recordPath = join(userDataPath, 'artifact-shares.json')
|
||||
|
||||
expect(() => writeDurableSecureJsonFile(recordPath, { ok: true })).not.toThrow()
|
||||
expect(JSON.parse(readFileSync(recordPath, 'utf8'))).toEqual({ ok: true })
|
||||
|
||||
expect(() =>
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
{
|
||||
cloudUserId: 'user-a',
|
||||
cloudProfileId: 'profile-a',
|
||||
cloudOrganizationId: 'org-a',
|
||||
apiOrigin: 'https://share.onorca.dev'
|
||||
},
|
||||
'key-a',
|
||||
{ content: 'hello', contentType: 'text/markdown', fileName: 'report.md' }
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(() => clearArtifactCreateIntents('local-profile', userDataPath)).not.toThrow()
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { writeSecureJsonFile } from '../../shared/secure-file'
|
||||
import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../../shared/secure-file'
|
||||
import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths'
|
||||
|
||||
export type ArtifactShareScope = {
|
||||
@@ -183,7 +183,7 @@ export function saveArtifactShareRecord(
|
||||
const records = readRecords(profileId, userDataPath)
|
||||
records.shares[sourceKey] = { ...record, savedAt: Date.now() }
|
||||
records.shares = pruneRecords(records.shares, Date.now()).shares
|
||||
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
|
||||
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records)
|
||||
}
|
||||
|
||||
export function refreshArtifactShareRecordExpiration(
|
||||
@@ -228,7 +228,7 @@ export function removeArtifactShareRecords(
|
||||
delete records.shares[sourceKey]
|
||||
}
|
||||
}
|
||||
writeSecureJsonFile(recordPath(profileId, userDataPath), records)
|
||||
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), records)
|
||||
}
|
||||
|
||||
export function clearArtifactShareRecords(profileId: string, userDataPath: string): void {
|
||||
@@ -238,7 +238,7 @@ export function clearArtifactShareRecords(profileId: string, userDataPath: strin
|
||||
} catch {
|
||||
// Clearing must recover sign-out from an unreadable token index.
|
||||
}
|
||||
writeSecureJsonFile(recordPath(profileId, userDataPath), {
|
||||
writeDurableSecureJsonFile(recordPath(profileId, userDataPath), {
|
||||
version: 2,
|
||||
lifecycleGeneration: lifecycleGeneration + 1,
|
||||
lifecycleNonce: randomUUID(),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
export const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0'
|
||||
|
||||
// Two interchangeable Bitbucket Cloud auth shapes: a single access token (Bearer)
|
||||
// or an email + API token pair (HTTP Basic). Either satisfies `hasAuth`, and both
|
||||
// may come from env vars or the in-app credential store.
|
||||
export type BitbucketAuthConfig = {
|
||||
baseUrl: string
|
||||
accessToken: string | null
|
||||
email: string | null
|
||||
apiToken: string | null
|
||||
}
|
||||
|
||||
export function envValue(name: string): string | null {
|
||||
const value = process.env[name]?.trim() ?? ''
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function getEnvAuthConfig(): BitbucketAuthConfig {
|
||||
return {
|
||||
baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? DEFAULT_API_BASE_URL,
|
||||
accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN'),
|
||||
email: envValue('ORCA_BITBUCKET_EMAIL'),
|
||||
apiToken: envValue('ORCA_BITBUCKET_API_TOKEN')
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAuth(config: BitbucketAuthConfig): boolean {
|
||||
return Boolean(config.accessToken || (config.email && config.apiToken))
|
||||
}
|
||||
|
||||
export function authHeaders(config: BitbucketAuthConfig): Record<string, string> {
|
||||
if (config.accessToken) {
|
||||
return { Authorization: `Bearer ${config.accessToken}` }
|
||||
}
|
||||
if (config.email && config.apiToken) {
|
||||
const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64')
|
||||
return { Authorization: `Basic ${encoded}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -124,6 +124,105 @@ describe('Bitbucket client', () => {
|
||||
).resolves.toMatchObject({ number: 42 })
|
||||
})
|
||||
|
||||
it('hides a MERGED PR matched only by feature-branch name so a new PR can be created', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.includes('/statuses/build')) {
|
||||
return Response.json({ values: [] })
|
||||
}
|
||||
return Response.json({
|
||||
values: [
|
||||
{ ...bitbucketPr(7), state: 'MERGED', source: { branch: { name: 'feature/login' } } }
|
||||
]
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
// Why: a merged branch match is history. Returning it made eligibility
|
||||
// report "a pull request already exists" and blocked the branch's next PR.
|
||||
await expect(
|
||||
getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login')
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a DECLINED PR on a feature branch visible', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.includes('/statuses/build')) {
|
||||
return Response.json({ values: [] })
|
||||
}
|
||||
return Response.json({
|
||||
values: [
|
||||
{ ...bitbucketPr(9), state: 'DECLINED', source: { branch: { name: 'feature/login' } } }
|
||||
]
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
// Why: only merged matches are hidden. Hiding declined too made a declined
|
||||
// PR permanently invisible off the default branch, unlike every other provider.
|
||||
await expect(
|
||||
getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login')
|
||||
).resolves.toMatchObject({ number: 9, state: 'closed' })
|
||||
})
|
||||
|
||||
it('returns null instead of querying anonymously when no credential resolves', async () => {
|
||||
delete process.env.ORCA_BITBUCKET_EMAIL
|
||||
delete process.env.ORCA_BITBUCKET_API_TOKEN
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
// Why: an unauthenticated query 404s on private repos, which would read as
|
||||
// "no pull request" and offer Create for a branch that already has one.
|
||||
await expect(
|
||||
getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/login')
|
||||
).resolves.toBeNull()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches an explicitly linked DECLINED PR before the branch index', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/pullrequests/7')) {
|
||||
return Response.json({ ...bitbucketPr(7), state: 'DECLINED' })
|
||||
}
|
||||
if (url.includes('/statuses/build')) {
|
||||
return Response.json({ values: [] })
|
||||
}
|
||||
return new Response('branch index unavailable', { status: 503 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 7)
|
||||
).resolves.toMatchObject({ number: 7, state: 'closed' })
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).not.toContainEqual(
|
||||
expect.stringContaining('/pullrequests?')
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the branch index when a linked PR number is stale', async () => {
|
||||
const branchPR = bitbucketPr(7)
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/pullrequests/99')) {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
if (url.includes('/statuses/build')) {
|
||||
return Response.json({ values: [] })
|
||||
}
|
||||
return Response.json({
|
||||
values: [{ ...branchPR, source: { ...branchPR.source, branch: { name: 'feature/login' } } }]
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
getBitbucketPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/login', 99)
|
||||
).resolves.toMatchObject({ number: 7, state: 'open' })
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
expect.stringContaining('/pullrequests/99'),
|
||||
expect.stringContaining('/pullrequests?'),
|
||||
expect.stringContaining('/statuses/build')
|
||||
])
|
||||
})
|
||||
|
||||
it('fetches a branch pull request and commit build status', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
|
||||
if (url.includes('/statuses/build')) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { CheckStatus } from '../../shared/types'
|
||||
import {
|
||||
deriveBitbucketBuildStatus,
|
||||
@@ -8,25 +7,26 @@ import {
|
||||
type RawBitbucketBuildStatus,
|
||||
type RawBitbucketPullRequest
|
||||
} from './pull-request-mappers'
|
||||
import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch'
|
||||
import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref'
|
||||
import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch'
|
||||
import {
|
||||
getHostedReviewLocalGitOptions,
|
||||
type HostedReviewExecutionOptions
|
||||
} from '../source-control/hosted-review-git-options'
|
||||
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
|
||||
import { authHeaders, getEnvAuthConfig, hasAuth } from './bitbucket-auth-config'
|
||||
import { accountNameFromUser, fetchBitbucketUser } from './user-request'
|
||||
import {
|
||||
getStoredBitbucketCredentialError,
|
||||
getStoredBitbucketMetadata,
|
||||
hasStoredBitbucketCredential,
|
||||
loadStoredBitbucketSecret
|
||||
} from './credential-store'
|
||||
import { resolveBitbucketAuthConfig, storedAuthConfig } from './resolve-auth'
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0'
|
||||
const REQUEST_TIMEOUT_MS = 5000
|
||||
const ALL_PULL_REQUEST_STATES = ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'] as const
|
||||
|
||||
type BitbucketAuthConfig = {
|
||||
baseUrl: string
|
||||
accessToken: string | null
|
||||
email: string | null
|
||||
apiToken: string | null
|
||||
}
|
||||
|
||||
export type BitbucketAuthStatus = {
|
||||
configured: boolean
|
||||
authenticated: boolean
|
||||
@@ -38,42 +38,16 @@ type RequestOptions = {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function envValue(name: string): string | null {
|
||||
const value = process.env[name]?.trim() ?? ''
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function getAuthConfig(): BitbucketAuthConfig {
|
||||
return {
|
||||
baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? DEFAULT_API_BASE_URL,
|
||||
accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN'),
|
||||
email: envValue('ORCA_BITBUCKET_EMAIL'),
|
||||
apiToken: envValue('ORCA_BITBUCKET_API_TOKEN')
|
||||
}
|
||||
}
|
||||
|
||||
function hasAuth(config: BitbucketAuthConfig): boolean {
|
||||
return Boolean(config.accessToken || (config.email && config.apiToken))
|
||||
}
|
||||
|
||||
function authHeaders(config: BitbucketAuthConfig): Record<string, string> {
|
||||
if (config.accessToken) {
|
||||
return { Authorization: `Bearer ${config.accessToken}` }
|
||||
}
|
||||
if (config.email && config.apiToken) {
|
||||
const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64')
|
||||
return { Authorization: `Basic ${encoded}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function isStringArray(value: string | readonly string[]): value is readonly string[] {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): string {
|
||||
const config = getAuthConfig()
|
||||
const base = config.baseUrl.replace(/\/+$/, '')
|
||||
function apiUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
searchParams?: RequestOptions['searchParams']
|
||||
): string {
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const url = new URL(`${base}${path}`)
|
||||
if (searchParams) {
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
@@ -95,11 +69,24 @@ async function requestJson<T>(
|
||||
// Why: the existing-review lookup behind Create must distinguish a real
|
||||
// transport/auth failure from an accepted "no PR". When true, a failed request
|
||||
// throws instead of collapsing to null so callers never report false not_found.
|
||||
throwOnFailure = false
|
||||
throwOnFailure = false,
|
||||
// Why: a linked PR number can be stale (deleted PR, wrong repo). A 404 there
|
||||
// must fall through to the branch lookup rather than throw and hide the
|
||||
// branch's real review, so only that caller opts into it.
|
||||
notFoundIsNull = false
|
||||
): Promise<T | null> {
|
||||
const config = getAuthConfig()
|
||||
const config = resolveBitbucketAuthConfig()
|
||||
// Why: a denied keychain prompt leaves no usable credential. Issuing the
|
||||
// request anyway gets a 404 on private repos, which reads as "no pull
|
||||
// request" and offers Create for a branch that already has one.
|
||||
if (!hasAuth(config)) {
|
||||
if (throwOnFailure) {
|
||||
throw new Error('Bitbucket request failed: no usable credential')
|
||||
}
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await fetch(apiUrl(path, options.searchParams), {
|
||||
const response = await fetch(apiUrl(config.baseUrl, path, options.searchParams), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders(config)
|
||||
@@ -108,6 +95,9 @@ async function requestJson<T>(
|
||||
})
|
||||
if (!response.ok) {
|
||||
await cancelUnreadResponseBody(response)
|
||||
if (response.status === 404 && notFoundIsNull) {
|
||||
return null
|
||||
}
|
||||
if (throwOnFailure) {
|
||||
throw new Error(`Bitbucket request failed: HTTP ${response.status}`)
|
||||
}
|
||||
@@ -157,21 +147,37 @@ async function normalizePullRequest(
|
||||
return mapBitbucketPullRequest(raw, status)
|
||||
}
|
||||
|
||||
// Never decrypts. Env credentials are checked live; a stored credential is
|
||||
// revalidated only when its secret already sits in memory from an earlier API
|
||||
// call, and otherwise trusted from plaintext metadata — decrypting here would
|
||||
// prompt for keychain access every time Settings opens.
|
||||
export async function getBitbucketAuthStatus(): Promise<BitbucketAuthStatus> {
|
||||
const config = getAuthConfig()
|
||||
if (!hasAuth(config)) {
|
||||
return { configured: false, authenticated: false, account: null }
|
||||
const env = getEnvAuthConfig()
|
||||
if (hasAuth(env)) {
|
||||
const user = await fetchBitbucketUser(env)
|
||||
return {
|
||||
configured: true,
|
||||
authenticated: user !== null,
|
||||
account: accountNameFromUser(user)
|
||||
}
|
||||
}
|
||||
const user = await requestJson<{
|
||||
username?: string | null
|
||||
display_name?: string | null
|
||||
account_id?: string | null
|
||||
}>('/user', { timeoutMs: 4000 })
|
||||
return {
|
||||
configured: true,
|
||||
authenticated: user !== null,
|
||||
account: user?.username ?? user?.display_name ?? user?.account_id ?? null
|
||||
const metadata = getStoredBitbucketMetadata()
|
||||
if (metadata && hasStoredBitbucketCredential()) {
|
||||
if (getStoredBitbucketCredentialError()) {
|
||||
return { configured: true, authenticated: false, account: metadata.account }
|
||||
}
|
||||
const cached = loadStoredBitbucketSecret()
|
||||
if (!cached) {
|
||||
return { configured: true, authenticated: true, account: metadata.account }
|
||||
}
|
||||
const user = await fetchBitbucketUser(storedAuthConfig(metadata, cached))
|
||||
return {
|
||||
configured: true,
|
||||
authenticated: user !== null,
|
||||
account: accountNameFromUser(user) ?? metadata.account
|
||||
}
|
||||
}
|
||||
return { configured: false, authenticated: false, account: null }
|
||||
}
|
||||
|
||||
export async function getBitbucketPullRequest(
|
||||
@@ -216,6 +222,18 @@ export async function getBitbucketPullRequestForBranch(
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof linkedPRNumber === 'number') {
|
||||
const raw = await requestJson<RawBitbucketPullRequest>(
|
||||
`/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`,
|
||||
{},
|
||||
throwOnFailure,
|
||||
true
|
||||
)
|
||||
if (raw) {
|
||||
return normalizePullRequest(repo, raw)
|
||||
}
|
||||
}
|
||||
|
||||
if (branchName) {
|
||||
const query = [
|
||||
`source.branch.name = "${escapeBitbucketQueryString(branchName)}"`,
|
||||
@@ -235,10 +253,16 @@ export async function getBitbucketPullRequestForBranch(
|
||||
)
|
||||
const raw = list?.values?.[0]
|
||||
if (raw) {
|
||||
// Why (#9171): discard a non-open implicit branch match on the repo
|
||||
// default branch and fall through to the linked-number fallback below.
|
||||
const state = mapBitbucketPullRequestState(raw.state)
|
||||
// Why: a merged PR we only matched by branch name is history, not review
|
||||
// context (GitHub's isMergedImplicitPR rule). Keeping it reported "a pull
|
||||
// request already exists" and blocked the branch's next PR. Scoped to
|
||||
// merged so a declined PR stays visible off the default branch, matching
|
||||
// every other provider; the linked lookup above already returned early
|
||||
// for an explicitly linked review.
|
||||
const isMergedImplicitMatch = state === 'merged'
|
||||
const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({
|
||||
state: mapBitbucketPullRequestState(raw.state),
|
||||
state,
|
||||
reviewNumber: raw.id ?? null,
|
||||
linkedReviewNumber: linkedPRNumber,
|
||||
branchName,
|
||||
@@ -246,21 +270,13 @@ export async function getBitbucketPullRequestForBranch(
|
||||
connectionId,
|
||||
localGitOptions: getHostedReviewLocalGitOptions(options)
|
||||
})
|
||||
if (!hideOnDefaultBranch) {
|
||||
if (!isMergedImplicitMatch && !hideOnDefaultBranch) {
|
||||
return normalizePullRequest(repo, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof linkedPRNumber !== 'number') {
|
||||
return null
|
||||
}
|
||||
const raw = await requestJson<RawBitbucketPullRequest>(
|
||||
`/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`,
|
||||
{},
|
||||
throwOnFailure
|
||||
)
|
||||
return raw ? normalizePullRequest(repo, raw) : null
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const OLD_ENV = process.env
|
||||
const OLD_FETCH = globalThis.fetch
|
||||
let tempHome = ''
|
||||
|
||||
async function loadModule() {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString('utf-8')
|
||||
}
|
||||
}))
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
})
|
||||
return import('./credential-connection')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV }
|
||||
for (const key of [
|
||||
'ORCA_BITBUCKET_ACCESS_TOKEN',
|
||||
'ORCA_BITBUCKET_EMAIL',
|
||||
'ORCA_BITBUCKET_API_TOKEN',
|
||||
'ORCA_BITBUCKET_API_BASE_URL'
|
||||
]) {
|
||||
delete process.env[key]
|
||||
}
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'orca-bitbucket-conn-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = OLD_ENV
|
||||
globalThis.fetch = OLD_FETCH
|
||||
})
|
||||
|
||||
describe('Bitbucket credential connection', () => {
|
||||
it('verifies credentials before saving and reports a stored connection', async () => {
|
||||
const conn = await loadModule()
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ username: 'ada' })
|
||||
) as unknown as typeof fetch
|
||||
|
||||
await expect(
|
||||
conn.connectBitbucket({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
apiToken: 'tok'
|
||||
})
|
||||
).resolves.toEqual({ ok: true, account: 'ada' })
|
||||
|
||||
expect(conn.getBitbucketConnectionStatus()).toEqual({
|
||||
configured: true,
|
||||
source: 'stored',
|
||||
account: 'ada',
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
baseUrl: null
|
||||
})
|
||||
})
|
||||
|
||||
it('sends Basic auth built from the entered email and API token', async () => {
|
||||
const conn = await loadModule()
|
||||
const fetchSpy = vi.fn(async () => Response.json({ username: 'ada' }))
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch
|
||||
|
||||
await conn.connectBitbucket({ authMode: 'basic', email: 'ada@example.com', apiToken: 'tok' })
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.bitbucket.org/2.0/user')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
`Basic ${Buffer.from('ada@example.com:tok').toString('base64')}`
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects credentials that fail the /user check without saving them', async () => {
|
||||
const conn = await loadModule()
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response(null, { status: 401 })
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const result = await conn.connectBitbucket({
|
||||
authMode: 'token',
|
||||
accessToken: 'bad'
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
expect(conn.getBitbucketConnectionStatus().source).toBe('none')
|
||||
})
|
||||
|
||||
it('rejects an incomplete basic-auth credential before making a request', async () => {
|
||||
const conn = await loadModule()
|
||||
const fetchSpy = vi.fn()
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch
|
||||
|
||||
const result = await conn.connectBitbucket({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com'
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports environment variables as the source and takes precedence over stored creds', async () => {
|
||||
const conn = await loadModule()
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ username: 'ada' })
|
||||
) as unknown as typeof fetch
|
||||
await conn.connectBitbucket({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
apiToken: 'tok'
|
||||
})
|
||||
|
||||
process.env.ORCA_BITBUCKET_ACCESS_TOKEN = 'env-token'
|
||||
expect(conn.getBitbucketConnectionStatus()).toMatchObject({
|
||||
configured: true,
|
||||
source: 'environment',
|
||||
authMode: 'token'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an env API base URL override the stored one without env credentials', async () => {
|
||||
const conn = await loadModule()
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ username: 'ada' })
|
||||
) as unknown as typeof fetch
|
||||
await conn.connectBitbucket({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
apiToken: 'tok',
|
||||
baseUrl: 'https://stored.example.com/2.0'
|
||||
})
|
||||
|
||||
// Only the base URL is in the env, so `hasAuth(env)` is false — precedence
|
||||
// is per-setting, not all-or-nothing.
|
||||
process.env.ORCA_BITBUCKET_API_BASE_URL = 'https://env.example.com/2.0'
|
||||
const { resolveBitbucketAuthConfig } = await import('./resolve-auth')
|
||||
expect(resolveBitbucketAuthConfig().baseUrl).toBe('https://env.example.com/2.0')
|
||||
})
|
||||
|
||||
it('clears the stored connection on disconnect', async () => {
|
||||
const conn = await loadModule()
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ username: 'ada' })
|
||||
) as unknown as typeof fetch
|
||||
await conn.connectBitbucket({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
apiToken: 'tok'
|
||||
})
|
||||
|
||||
conn.disconnectBitbucket()
|
||||
expect(conn.getBitbucketConnectionStatus().source).toBe('none')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
DEFAULT_API_BASE_URL,
|
||||
envValue,
|
||||
getEnvAuthConfig,
|
||||
hasAuth,
|
||||
type BitbucketAuthConfig
|
||||
} from './bitbucket-auth-config'
|
||||
import { accountNameFromUser, fetchBitbucketUser } from './user-request'
|
||||
import {
|
||||
clearStoredBitbucketCredential,
|
||||
getStoredBitbucketMetadata,
|
||||
hasStoredBitbucketCredential,
|
||||
saveBitbucketCredential
|
||||
} from './credential-store'
|
||||
import type {
|
||||
BitbucketConnectArgs,
|
||||
BitbucketConnectResult,
|
||||
BitbucketConnectionStatus
|
||||
} from '../../shared/bitbucket-credentials'
|
||||
|
||||
export type { BitbucketConnectArgs, BitbucketConnectResult, BitbucketConnectionStatus }
|
||||
|
||||
const VERIFY_TIMEOUT_MS = 6000
|
||||
|
||||
function normalize(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim() ?? ''
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function buildCandidateConfig(input: BitbucketConnectArgs): BitbucketAuthConfig {
|
||||
return {
|
||||
baseUrl: normalize(input.baseUrl) ?? DEFAULT_API_BASE_URL,
|
||||
accessToken: input.authMode === 'token' ? normalize(input.accessToken) : null,
|
||||
email: input.authMode === 'basic' ? normalize(input.email) : null,
|
||||
apiToken: input.authMode === 'basic' ? normalize(input.apiToken) : null
|
||||
}
|
||||
}
|
||||
|
||||
// Verifying against `/user` before persisting keeps the stored "connected
|
||||
// account" honest and lets the dialog reject a dead token inline.
|
||||
export async function connectBitbucket(
|
||||
input: BitbucketConnectArgs
|
||||
): Promise<BitbucketConnectResult> {
|
||||
const config = buildCandidateConfig(input)
|
||||
if (!hasAuth(config)) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
input.authMode === 'token'
|
||||
? 'Enter an access token.'
|
||||
: 'Enter both an email and an API token.'
|
||||
}
|
||||
}
|
||||
const user = await fetchBitbucketUser(config, VERIFY_TIMEOUT_MS)
|
||||
if (!user) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Could not authenticate with Bitbucket. Check the credentials and try again.'
|
||||
}
|
||||
}
|
||||
const account = accountNameFromUser(user)
|
||||
saveBitbucketCredential({
|
||||
authMode: input.authMode,
|
||||
email: config.email,
|
||||
baseUrl: normalize(input.baseUrl),
|
||||
account,
|
||||
accessToken: config.accessToken,
|
||||
apiToken: config.apiToken
|
||||
})
|
||||
return { ok: true, account }
|
||||
}
|
||||
|
||||
export function disconnectBitbucket(): void {
|
||||
clearStoredBitbucketCredential()
|
||||
}
|
||||
|
||||
// Reads env vars and plaintext metadata only — never decrypts — so the Settings
|
||||
// card can call it on every open without a keychain prompt.
|
||||
export function getBitbucketConnectionStatus(): BitbucketConnectionStatus {
|
||||
const env = getEnvAuthConfig()
|
||||
if (hasAuth(env)) {
|
||||
return {
|
||||
configured: true,
|
||||
source: 'environment',
|
||||
account: null,
|
||||
authMode: env.accessToken ? 'token' : 'basic',
|
||||
email: env.email,
|
||||
baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL')
|
||||
}
|
||||
}
|
||||
if (hasStoredBitbucketCredential()) {
|
||||
const metadata = getStoredBitbucketMetadata()
|
||||
return {
|
||||
configured: true,
|
||||
source: 'stored',
|
||||
account: metadata?.account ?? null,
|
||||
authMode: metadata?.authMode ?? null,
|
||||
email: metadata?.email ?? null,
|
||||
baseUrl: metadata?.baseUrl ?? null
|
||||
}
|
||||
}
|
||||
return {
|
||||
configured: false,
|
||||
source: 'none',
|
||||
account: null,
|
||||
authMode: null,
|
||||
email: null,
|
||||
baseUrl: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { existsSync, mkdtempSync, statSync } from 'node:fs'
|
||||
import type * as Fs from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
let tempHome = ''
|
||||
const decryptStringMock = vi.fn((value: Buffer) => value.toString('utf-8'))
|
||||
|
||||
async function loadStore(options: { unlinkError?: NodeJS.ErrnoException } = {}) {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: decryptStringMock
|
||||
}
|
||||
}))
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
})
|
||||
if (options.unlinkError) {
|
||||
// Why mocked: chmod-based failure injection is not portable — Windows has no
|
||||
// POSIX modes and a root/elevated runner can unlink through a 0500 directory.
|
||||
const error = options.unlinkError
|
||||
vi.doMock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof Fs>('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
unlinkSync: () => {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return import('./credential-store')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'orca-bitbucket-store-'))
|
||||
decryptStringMock.mockClear()
|
||||
})
|
||||
|
||||
describe('Bitbucket credential store', () => {
|
||||
it('persists plaintext metadata and an encrypted secret, then reads them back', async () => {
|
||||
const store = await loadStore()
|
||||
store.saveBitbucketCredential({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
baseUrl: null,
|
||||
account: 'ada',
|
||||
accessToken: null,
|
||||
apiToken: 'secret-token'
|
||||
})
|
||||
|
||||
expect(store.hasStoredBitbucketCredential()).toBe(true)
|
||||
expect(store.getStoredBitbucketMetadata()).toMatchObject({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
account: 'ada'
|
||||
})
|
||||
expect(store.loadStoredBitbucketSecret()).toEqual({
|
||||
accessToken: null,
|
||||
apiToken: 'secret-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes both credential files 0600', async () => {
|
||||
const store = await loadStore()
|
||||
store.saveBitbucketCredential({
|
||||
authMode: 'token',
|
||||
email: null,
|
||||
baseUrl: null,
|
||||
account: 'dev',
|
||||
accessToken: 'access-secret',
|
||||
apiToken: null
|
||||
})
|
||||
|
||||
for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) {
|
||||
expect(statSync(join(tempHome, '.orca', file)).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
it('re-tightens permissions when overwriting an existing credential', async () => {
|
||||
const store = await loadStore()
|
||||
const save = (account: string): void =>
|
||||
store.saveBitbucketCredential({
|
||||
authMode: 'token',
|
||||
email: null,
|
||||
baseUrl: null,
|
||||
account,
|
||||
accessToken: 'access-secret',
|
||||
apiToken: null
|
||||
})
|
||||
save('first')
|
||||
// Why: writeFileSync's mode is ignored for an existing file, so a loosened
|
||||
// credential would stay world-readable across a reconnect.
|
||||
const { chmodSync } = await import('node:fs')
|
||||
for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) {
|
||||
chmodSync(join(tempHome, '.orca', file), 0o644)
|
||||
}
|
||||
save('second')
|
||||
|
||||
for (const file of ['bitbucket-credential.enc', 'bitbucket-credential.json']) {
|
||||
expect(statSync(join(tempHome, '.orca', file)).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not decrypt for metadata/status reads — only on a forced secret load', async () => {
|
||||
const store = await loadStore()
|
||||
store.saveBitbucketCredential({
|
||||
authMode: 'token',
|
||||
email: null,
|
||||
baseUrl: 'https://api.bitbucket.org/2.0',
|
||||
account: 'dev',
|
||||
accessToken: 'access-secret',
|
||||
apiToken: null
|
||||
})
|
||||
|
||||
// Simulate a fresh session: caches cleared, files still on disk.
|
||||
store._resetBitbucketCredentialCache()
|
||||
|
||||
expect(store.getStoredBitbucketMetadata()?.account).toBe('dev')
|
||||
expect(store.hasStoredBitbucketCredential()).toBe(true)
|
||||
expect(decryptStringMock).not.toHaveBeenCalled()
|
||||
|
||||
// Without force, the secret stays unread.
|
||||
expect(store.loadStoredBitbucketSecret()).toBeNull()
|
||||
expect(decryptStringMock).not.toHaveBeenCalled()
|
||||
|
||||
// Forcing the load decrypts exactly once, then caches.
|
||||
expect(store.loadStoredBitbucketSecret({ force: true })).toEqual({
|
||||
accessToken: 'access-secret',
|
||||
apiToken: null
|
||||
})
|
||||
expect(decryptStringMock).toHaveBeenCalledTimes(1)
|
||||
expect(store.loadStoredBitbucketSecret()).not.toBeNull()
|
||||
expect(decryptStringMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects non-string fields from hand-edited metadata and secret files', async () => {
|
||||
const store = await loadStore()
|
||||
const { writeFileSync } = await import('node:fs')
|
||||
store.saveBitbucketCredential({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
baseUrl: null,
|
||||
account: 'ada',
|
||||
accessToken: null,
|
||||
apiToken: 'secret-token'
|
||||
})
|
||||
|
||||
writeFileSync(
|
||||
join(tempHome, '.orca', 'bitbucket-credential.json'),
|
||||
JSON.stringify({ version: 1, authMode: 'basic', email: { evil: true }, account: 42 })
|
||||
)
|
||||
writeFileSync(
|
||||
join(tempHome, '.orca', 'bitbucket-credential.enc'),
|
||||
JSON.stringify({ accessToken: ['nope'], apiToken: 7 })
|
||||
)
|
||||
store._resetBitbucketCredentialCache()
|
||||
|
||||
expect(store.getStoredBitbucketMetadata()).toMatchObject({ email: null, account: null })
|
||||
expect(store.loadStoredBitbucketSecret({ force: true })).toEqual({
|
||||
accessToken: null,
|
||||
apiToken: null
|
||||
})
|
||||
})
|
||||
|
||||
it('clears both files and in-memory state on disconnect', async () => {
|
||||
const store = await loadStore()
|
||||
store.saveBitbucketCredential({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
baseUrl: null,
|
||||
account: 'ada',
|
||||
accessToken: null,
|
||||
apiToken: 'secret-token'
|
||||
})
|
||||
|
||||
store.clearStoredBitbucketCredential()
|
||||
|
||||
expect(store.hasStoredBitbucketCredential()).toBe(false)
|
||||
expect(store.getStoredBitbucketMetadata()).toBeNull()
|
||||
expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toBe(false)
|
||||
expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces a non-ENOENT delete failure instead of silently keeping the files', async () => {
|
||||
const denied: NodeJS.ErrnoException = Object.assign(new Error('permission denied'), {
|
||||
code: 'EACCES'
|
||||
})
|
||||
const store = await loadStore({ unlinkError: denied })
|
||||
store.saveBitbucketCredential({
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
baseUrl: null,
|
||||
account: 'ada',
|
||||
accessToken: null,
|
||||
apiToken: 'secret-token'
|
||||
})
|
||||
|
||||
// Clearing memory while the files survive would resurrect the credential on
|
||||
// the next launch, so the failure has to reach the caller.
|
||||
expect(() => store.clearStoredBitbucketCredential()).toThrow(/permission denied/)
|
||||
expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a missing file on disconnect', async () => {
|
||||
const missing: NodeJS.ErrnoException = Object.assign(new Error('no such file'), {
|
||||
code: 'ENOENT'
|
||||
})
|
||||
const store = await loadStore({ unlinkError: missing })
|
||||
expect(() => store.clearStoredBitbucketCredential()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
CredentialDecryptionError,
|
||||
credentialFileHasContent,
|
||||
readStoredCredentialToken,
|
||||
restrictCredentialFileToOwner,
|
||||
writeEncryptedCredential
|
||||
} from '../integration-credential-file'
|
||||
import type { BitbucketAuthMode } from '../../shared/bitbucket-credentials'
|
||||
|
||||
// Why: the secret stays encrypted via safeStorage while this metadata stays
|
||||
// plaintext, so status reads render the connected account without decrypting —
|
||||
// otherwise every Settings open would trigger an OS keychain prompt.
|
||||
export type BitbucketStoredMetadata = {
|
||||
version: 1
|
||||
authMode: BitbucketAuthMode
|
||||
email: string | null
|
||||
baseUrl: string | null
|
||||
account: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type BitbucketStoredSecret = {
|
||||
accessToken: string | null
|
||||
apiToken: string | null
|
||||
}
|
||||
|
||||
export type BitbucketCredentialSaveInput = {
|
||||
authMode: BitbucketAuthMode
|
||||
email: string | null
|
||||
baseUrl: string | null
|
||||
account: string | null
|
||||
accessToken: string | null
|
||||
apiToken: string | null
|
||||
}
|
||||
|
||||
let cachedMetadata: BitbucketStoredMetadata | null = null
|
||||
let metadataLoadedFromDisk = false
|
||||
let cachedSecret: BitbucketStoredSecret | null = null
|
||||
let credentialError: string | null = null
|
||||
|
||||
function getOrcaDir(): string {
|
||||
return join(homedir(), '.orca')
|
||||
}
|
||||
|
||||
function getMetadataPath(): string {
|
||||
return join(getOrcaDir(), 'bitbucket-credential.json')
|
||||
}
|
||||
|
||||
function getSecretPath(): string {
|
||||
return join(getOrcaDir(), 'bitbucket-credential.enc')
|
||||
}
|
||||
|
||||
function ensureOrcaDir(): void {
|
||||
const dir = getOrcaDir()
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Why: hand-edited or truncated JSON must not put a non-string into an auth
|
||||
// header, so every field is narrowed rather than trusted.
|
||||
function asOptionalString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
function readMetadataFromDisk(): BitbucketStoredMetadata | null {
|
||||
const path = getMetadataPath()
|
||||
if (!existsSync(path)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<BitbucketStoredMetadata>
|
||||
if (parsed.authMode !== 'token' && parsed.authMode !== 'basic') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
authMode: parsed.authMode,
|
||||
email: asOptionalString(parsed.email),
|
||||
baseUrl: asOptionalString(parsed.baseUrl),
|
||||
account: asOptionalString(parsed.account),
|
||||
updatedAt: asOptionalString(parsed.updatedAt) ?? ''
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredBitbucketMetadata(): BitbucketStoredMetadata | null {
|
||||
if (!metadataLoadedFromDisk) {
|
||||
cachedMetadata = readMetadataFromDisk()
|
||||
metadataLoadedFromDisk = true
|
||||
}
|
||||
return cachedMetadata
|
||||
}
|
||||
|
||||
// Cheap "is a credential saved?" check: metadata present and a non-empty secret
|
||||
// file. Never decrypts, so it is safe on every status poll.
|
||||
export function hasStoredBitbucketCredential(): boolean {
|
||||
return getStoredBitbucketMetadata() !== null && credentialFileHasContent(getSecretPath())
|
||||
}
|
||||
|
||||
export function getStoredBitbucketCredentialError(): string | null {
|
||||
return credentialError
|
||||
}
|
||||
|
||||
// Cache-first; only touches the keychain when `force` is set and the secret is
|
||||
// not already in memory (mirrors Linear's `loadToken({ force })`). Throws
|
||||
// CredentialDecryptionError when ciphertext cannot be decrypted.
|
||||
export function loadStoredBitbucketSecret(
|
||||
options: { force?: boolean } = {}
|
||||
): BitbucketStoredSecret | null {
|
||||
if (cachedSecret !== null) {
|
||||
return cachedSecret
|
||||
}
|
||||
if (!options.force) {
|
||||
return null
|
||||
}
|
||||
const path = getSecretPath()
|
||||
if (!existsSync(path)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const token = readStoredCredentialToken('Bitbucket', readFileSync(path))
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
const parsed = JSON.parse(token) as Partial<BitbucketStoredSecret>
|
||||
cachedSecret = {
|
||||
accessToken: asOptionalString(parsed.accessToken),
|
||||
apiToken: asOptionalString(parsed.apiToken)
|
||||
}
|
||||
credentialError = null
|
||||
return cachedSecret
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialDecryptionError) {
|
||||
credentialError = error.message
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveBitbucketCredential(input: BitbucketCredentialSaveInput): void {
|
||||
ensureOrcaDir()
|
||||
const secret: BitbucketStoredSecret = {
|
||||
accessToken: input.accessToken,
|
||||
apiToken: input.apiToken
|
||||
}
|
||||
writeEncryptedCredential('Bitbucket', getSecretPath(), JSON.stringify(secret))
|
||||
const metadata: BitbucketStoredMetadata = {
|
||||
version: 1,
|
||||
authMode: input.authMode,
|
||||
email: input.email,
|
||||
baseUrl: input.baseUrl,
|
||||
account: input.account,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
writeFileSync(getMetadataPath(), JSON.stringify(metadata, null, 2), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600
|
||||
})
|
||||
restrictCredentialFileToOwner(getMetadataPath())
|
||||
cachedMetadata = metadata
|
||||
metadataLoadedFromDisk = true
|
||||
cachedSecret = secret
|
||||
credentialError = null
|
||||
}
|
||||
|
||||
// Why: swallowing a non-ENOENT unlink failure would clear memory while the
|
||||
// files survive, so the credential silently returns on the next launch.
|
||||
export function clearStoredBitbucketCredential(): void {
|
||||
try {
|
||||
for (const path of [getSecretPath(), getMetadataPath()]) {
|
||||
try {
|
||||
unlinkSync(path)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Why: on a partial delete the caches must still be dropped, or the session
|
||||
// keeps authenticating with a credential the user just disconnected.
|
||||
cachedMetadata = null
|
||||
metadataLoadedFromDisk = true
|
||||
cachedSecret = null
|
||||
credentialError = null
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal - tests need a clean in-memory cache between cases. */
|
||||
export function _resetBitbucketCredentialCache(): void {
|
||||
cachedMetadata = null
|
||||
metadataLoadedFromDisk = false
|
||||
cachedSecret = null
|
||||
credentialError = null
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createBitbucketPullRequest,
|
||||
isBitbucketReviewCreationAuthenticated
|
||||
} from './pull-request-creation'
|
||||
import { _resetBitbucketRepoRefCache } from './repository-ref'
|
||||
|
||||
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: () => 0
|
||||
}))
|
||||
|
||||
vi.mock('../source-control/pull-request-template', () => ({
|
||||
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
|
||||
}))
|
||||
|
||||
const OLD_ENV = process.env
|
||||
const OLD_FETCH = globalThis.fetch
|
||||
|
||||
const CREATE_INPUT = {
|
||||
provider: 'bitbucket',
|
||||
base: 'main',
|
||||
head: 'feature/login',
|
||||
title: 'Add login'
|
||||
} as const
|
||||
|
||||
function createdPullRequestResponse(): Response {
|
||||
return Response.json({
|
||||
id: 42,
|
||||
title: 'Add login',
|
||||
state: 'OPEN',
|
||||
updated_on: '2026-08-11T00:00:00Z',
|
||||
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } },
|
||||
source: { branch: { name: 'feature/login' } },
|
||||
destination: { branch: { name: 'main' } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('Bitbucket pull request creation', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' }
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://bitbucket.org/team/repo.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
_resetBitbucketRepoRefCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = OLD_ENV
|
||||
globalThis.fetch = OLD_FETCH
|
||||
_resetBitbucketRepoRefCache()
|
||||
})
|
||||
|
||||
it('posts the Bitbucket source/destination create body to the repository endpoint', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(String(input))
|
||||
expect(url.origin).toBe('https://api.bitbucket.org')
|
||||
expect(url.pathname).toBe('/2.0/repositories/team/repo/pullrequests')
|
||||
const requestInit = init!
|
||||
expect(requestInit.method).toBe('POST')
|
||||
expect((requestInit.headers as Record<string, string>).Authorization).toBe('Bearer bb-token')
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
title: 'Add login',
|
||||
description: '',
|
||||
source: { branch: { name: 'feature/login' } },
|
||||
destination: { branch: { name: 'main' } }
|
||||
})
|
||||
return createdPullRequestResponse()
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
await expect(createBitbucketPullRequest('/repo', CREATE_INPUT)).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 42,
|
||||
url: 'https://bitbucket.org/team/repo/pull-requests/42'
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the stored credential when no environment variable is set', async () => {
|
||||
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
|
||||
const fetchMock = vi.fn(async () => createdPullRequestResponse())
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
const result = await createBitbucketPullRequest('/repo', CREATE_INPUT)
|
||||
|
||||
// No env var and no stored credential: fail closed rather than POST anonymously.
|
||||
expect(result).toMatchObject({ ok: false, code: 'auth_required' })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a draft request rather than dead-ending a hidden persisted default', async () => {
|
||||
const fetchMock = vi.fn(async () => createdPullRequestResponse())
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
// Why: the composer hides the Draft toggle for Bitbucket, so a `true` here
|
||||
// is an unreachable persisted default the user cannot clear.
|
||||
await expect(
|
||||
createBitbucketPullRequest('/repo', { ...CREATE_INPUT, draft: true })
|
||||
).resolves.toMatchObject({ ok: true, number: 42 })
|
||||
expect(JSON.parse(String((fetchMock.mock.calls[0] as never[])[1]['body']))).not.toHaveProperty(
|
||||
'draft'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports authenticated only when a credential resolves', async () => {
|
||||
expect(isBitbucketReviewCreationAuthenticated()).toBe(true)
|
||||
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
|
||||
expect(isBitbucketReviewCreationAuthenticated()).toBe(false)
|
||||
})
|
||||
|
||||
it('maps a duplicate-branch rejection to already_exists with the existing review', async () => {
|
||||
let call = 0
|
||||
const fetchMock = vi.fn(async () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
return Response.json(
|
||||
{ error: { message: 'There is already a pull request for this branch.' } },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
return Response.json({
|
||||
values: [
|
||||
{
|
||||
id: 7,
|
||||
title: 'Add login',
|
||||
state: 'OPEN',
|
||||
updated_on: '2026-08-11T00:00:00Z',
|
||||
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/7' } },
|
||||
source: { branch: { name: 'feature/login' } },
|
||||
destination: { branch: { name: 'main' } }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
expect(await createBitbucketPullRequest('/repo', CREATE_INPUT)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
existingReview: { number: 7, url: 'https://bitbucket.org/team/repo/pull-requests/7' }
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a 401 to auth_required pointing at both credential paths', async () => {
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
Response.json({ error: { message: 'Unauthorized' } }, { status: 401 })
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const result = await createBitbucketPullRequest('/repo', CREATE_INPUT)
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: 'auth_required' })
|
||||
expect(!result.ok && result.error).toContain('Settings')
|
||||
})
|
||||
|
||||
it('refuses a non-Bitbucket remote', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'https://github.com/team/repo.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
globalThis.fetch = vi.fn() as unknown as typeof fetch
|
||||
|
||||
await expect(createBitbucketPullRequest('/repo', CREATE_INPUT)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'unsupported_provider'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
|
||||
import {
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
} from '../../shared/hosted-review-refs'
|
||||
import {
|
||||
HostedReviewApiRequestError,
|
||||
requestHostedReviewJson
|
||||
} from '../source-control/hosted-review-api-request'
|
||||
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
|
||||
import { authHeaders, hasAuth } from './bitbucket-auth-config'
|
||||
import { resolveBitbucketAuthConfig } from './resolve-auth'
|
||||
import { getBitbucketPullRequestForBranch } from './client'
|
||||
import { mapBitbucketPullRequest, type RawBitbucketPullRequest } from './pull-request-mappers'
|
||||
import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref'
|
||||
import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options'
|
||||
import { getHostedReviewLocalGitOptions } from '../source-control/hosted-review-git-options'
|
||||
|
||||
const CREATE_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
export function isBitbucketReviewCreationAuthenticated(): boolean {
|
||||
return hasAuth(resolveBitbucketAuthConfig())
|
||||
}
|
||||
|
||||
function encodedRepoPath(repo: BitbucketRepoRef): string {
|
||||
return `${encodeURIComponent(repo.workspace)}/${encodeURIComponent(repo.repoSlug)}`
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function classifyCreateError(error: unknown): CreateHostedReviewResult {
|
||||
const message = apiErrorMessage(error)
|
||||
if (message) {
|
||||
console.warn('createBitbucketPullRequest failed:', message)
|
||||
}
|
||||
const lower = message.toLowerCase()
|
||||
const status = error instanceof HostedReviewApiRequestError ? error.status : null
|
||||
if (status === 401 || status === 403 || lower.includes('unauthorized')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'auth_required',
|
||||
error:
|
||||
'Create PR failed: Bitbucket is not authenticated. Next step: connect Bitbucket in Settings > Integrations, or set ORCA_BITBUCKET_* in this environment.'
|
||||
}
|
||||
}
|
||||
// Bitbucket answers a duplicate source branch with 400 plus this phrasing
|
||||
// rather than a 409, so the text is the only reliable signal.
|
||||
if (lower.includes('already exists') || lower.includes('already a pull request')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.'
|
||||
}
|
||||
}
|
||||
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown_completion',
|
||||
error: 'PR creation may have completed. Refreshing branch review state...'
|
||||
}
|
||||
}
|
||||
if (status === 400 || status === 422) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error:
|
||||
'Create PR failed: Bitbucket rejected the pull request. Check the base branch and branch state, then try again.'
|
||||
}
|
||||
}
|
||||
if (status === 404) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error:
|
||||
'Create PR failed: Bitbucket could not find the repository. Check that the credential has write access to it.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown',
|
||||
error: 'Create PR failed: Bitbucket could not create the pull request. Try again in a moment.'
|
||||
}
|
||||
}
|
||||
|
||||
async function findExistingPullRequest(
|
||||
repoPath: string,
|
||||
head: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<{ number: number; url: string } | null> {
|
||||
const existing = await getBitbucketPullRequestForBranch(
|
||||
repoPath,
|
||||
head,
|
||||
null,
|
||||
connectionId,
|
||||
options
|
||||
)
|
||||
return existing ? { number: existing.number, url: existing.url } : null
|
||||
}
|
||||
|
||||
export async function createBitbucketPullRequest(
|
||||
repoPath: string,
|
||||
input: CreateHostedReviewInput,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<CreateHostedReviewResult> {
|
||||
if (input.provider !== 'bitbucket') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
error: 'Creating reviews for this provider is not supported yet.'
|
||||
}
|
||||
}
|
||||
|
||||
const config = resolveBitbucketAuthConfig()
|
||||
if (!hasAuth(config)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'auth_required',
|
||||
error:
|
||||
'Create PR failed: Bitbucket is not connected. Next step: connect Bitbucket in Settings > Integrations.'
|
||||
}
|
||||
}
|
||||
|
||||
const repo = await getBitbucketRepoRef(
|
||||
repoPath,
|
||||
connectionId,
|
||||
getHostedReviewLocalGitOptions(options)
|
||||
)
|
||||
if (!repo) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
error: 'Creating pull requests requires a Bitbucket remote.'
|
||||
}
|
||||
}
|
||||
|
||||
const base = normalizeHostedReviewBaseRef(input.base)
|
||||
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
|
||||
const title = input.title.trim()
|
||||
if (!base || !head || !title) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error: 'Create PR failed: base branch, head branch, and title are required.'
|
||||
}
|
||||
}
|
||||
if (head.toLowerCase() === base.toLowerCase()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error: 'Create PR failed: choose a different base branch before creating a pull request.'
|
||||
}
|
||||
}
|
||||
// Why: Bitbucket Cloud has no draft pull requests and the composer hides the
|
||||
// toggle, so `draft` here can only be an unreachable persisted default —
|
||||
// rejecting it would dead-end the user with no control to clear.
|
||||
const description =
|
||||
input.useTemplate && !input.body?.trim()
|
||||
? await readHostedPullRequestTemplate(repoPath, connectionId)
|
||||
: (input.body ?? '')
|
||||
const requestBody = {
|
||||
title,
|
||||
description,
|
||||
source: { branch: { name: head } },
|
||||
destination: { branch: { name: base } }
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await requestHostedReviewJson<RawBitbucketPullRequest>(
|
||||
new URL(
|
||||
`${config.baseUrl.replace(/\/+$/, '')}/repositories/${encodedRepoPath(repo)}/pullrequests`
|
||||
),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(config)
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
},
|
||||
CREATE_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
const created = mapBitbucketPullRequest(raw, 'neutral')
|
||||
if (created) {
|
||||
return { ok: true, number: created.number, url: created.url }
|
||||
}
|
||||
const found = await findExistingPullRequest(repoPath, head, connectionId, options).catch(
|
||||
() => null
|
||||
)
|
||||
return found
|
||||
? { ok: true, ...found }
|
||||
: {
|
||||
ok: false,
|
||||
code: 'unknown_completion',
|
||||
error: 'PR creation may have completed. Refreshing branch review state...'
|
||||
}
|
||||
} catch (error) {
|
||||
const classified = classifyCreateError(error)
|
||||
if (
|
||||
!classified.ok &&
|
||||
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
|
||||
) {
|
||||
const existing = await findExistingPullRequest(repoPath, head, connectionId, options).catch(
|
||||
() => null
|
||||
)
|
||||
if (existing) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.',
|
||||
existingReview: existing
|
||||
}
|
||||
}
|
||||
}
|
||||
return classified
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
DEFAULT_API_BASE_URL,
|
||||
envValue,
|
||||
getEnvAuthConfig,
|
||||
hasAuth,
|
||||
type BitbucketAuthConfig
|
||||
} from './bitbucket-auth-config'
|
||||
import {
|
||||
getStoredBitbucketMetadata,
|
||||
loadStoredBitbucketSecret,
|
||||
type BitbucketStoredMetadata,
|
||||
type BitbucketStoredSecret
|
||||
} from './credential-store'
|
||||
|
||||
export function storedAuthConfig(
|
||||
metadata: BitbucketStoredMetadata,
|
||||
secret: BitbucketStoredSecret
|
||||
): BitbucketAuthConfig {
|
||||
return {
|
||||
// Why: an explicit ORCA_BITBUCKET_API_BASE_URL still wins even when the
|
||||
// credential itself is stored — env precedence is per-setting, not all-or-nothing.
|
||||
baseUrl: envValue('ORCA_BITBUCKET_API_BASE_URL') ?? metadata.baseUrl ?? DEFAULT_API_BASE_URL,
|
||||
accessToken: metadata.authMode === 'token' ? secret.accessToken : null,
|
||||
email: metadata.authMode === 'basic' ? metadata.email : null,
|
||||
apiToken: metadata.authMode === 'basic' ? secret.apiToken : null
|
||||
}
|
||||
}
|
||||
|
||||
// Env vars win over in-app credentials so existing headless/SSH setups keep
|
||||
// working unchanged. The stored secret is decrypted lazily and only here, on a
|
||||
// real API call — never on a status read.
|
||||
export function resolveBitbucketAuthConfig(): BitbucketAuthConfig {
|
||||
const env = getEnvAuthConfig()
|
||||
if (hasAuth(env)) {
|
||||
return env
|
||||
}
|
||||
const metadata = getStoredBitbucketMetadata()
|
||||
if (!metadata) {
|
||||
return env
|
||||
}
|
||||
try {
|
||||
const secret = loadStoredBitbucketSecret({ force: true })
|
||||
return secret ? storedAuthConfig(metadata, secret) : env
|
||||
} catch {
|
||||
// Decryption denied or unavailable: fall through as unauthenticated.
|
||||
return env
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Why: opening Settings renders the Bitbucket card, which runs the preflight
|
||||
// status (getBitbucketAuthStatus) and the card's status IPC
|
||||
// (getBitbucketConnectionStatus). Neither may decrypt the stored secret, or the
|
||||
// OS would prompt to unlock the keychain on every Settings open. These tests
|
||||
// simulate a cold session (files on disk, memory cache empty).
|
||||
|
||||
const decryptSpy = vi.fn((value: Buffer) => value.toString('utf-8'))
|
||||
const OLD_ENV = process.env
|
||||
const OLD_FETCH = globalThis.fetch
|
||||
let tempHome = ''
|
||||
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() }))
|
||||
|
||||
async function loadModules() {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: decryptSpy
|
||||
}
|
||||
}))
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
})
|
||||
// Stub repo detection so the PR fetch reaches the auth-resolution path.
|
||||
vi.doMock('./repository-ref', () => ({
|
||||
getBitbucketRepoRef: async () => ({ workspace: 'acme', repoSlug: 'app' })
|
||||
}))
|
||||
const store = await import('./credential-store')
|
||||
const client = await import('./client')
|
||||
const connection = await import('./credential-connection')
|
||||
return { store, client, connection }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...OLD_ENV }
|
||||
for (const key of [
|
||||
'ORCA_BITBUCKET_ACCESS_TOKEN',
|
||||
'ORCA_BITBUCKET_EMAIL',
|
||||
'ORCA_BITBUCKET_API_TOKEN',
|
||||
'ORCA_BITBUCKET_API_BASE_URL'
|
||||
]) {
|
||||
delete process.env[key]
|
||||
}
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'orca-bb-nodecrypt-'))
|
||||
decryptSpy.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = OLD_ENV
|
||||
globalThis.fetch = OLD_FETCH
|
||||
})
|
||||
|
||||
const STORED_BASIC_CREDENTIAL = {
|
||||
authMode: 'basic',
|
||||
email: 'ada@example.com',
|
||||
baseUrl: null,
|
||||
account: 'ada',
|
||||
accessToken: null,
|
||||
apiToken: 'secret-token'
|
||||
} as const
|
||||
|
||||
describe('Bitbucket status reads never decrypt the stored secret', () => {
|
||||
it('renders connected state from plaintext metadata without touching the keychain', async () => {
|
||||
const { store, client, connection } = await loadModules()
|
||||
const fetchSpy = vi.fn()
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch
|
||||
store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL)
|
||||
|
||||
// Simulate relaunch: secret is on disk but the memory cache is cold, so a
|
||||
// decrypt here would surface to the user as a keychain prompt.
|
||||
store._resetBitbucketCredentialCache()
|
||||
decryptSpy.mockClear()
|
||||
|
||||
const auth = await client.getBitbucketAuthStatus()
|
||||
const status = connection.getBitbucketConnectionStatus()
|
||||
|
||||
expect(auth).toEqual({ configured: true, authenticated: true, account: 'ada' })
|
||||
expect(status).toMatchObject({ source: 'stored', account: 'ada', authMode: 'basic' })
|
||||
expect(decryptSpy).not.toHaveBeenCalled()
|
||||
// A stored credential is trusted from metadata rather than revalidated.
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays cold across repeated status reads, then decrypts once for a real API call', async () => {
|
||||
const { store, client, connection } = await loadModules()
|
||||
store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL)
|
||||
store._resetBitbucketCredentialCache()
|
||||
decryptSpy.mockClear()
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await client.getBitbucketAuthStatus()
|
||||
connection.getBitbucketConnectionStatus()
|
||||
}
|
||||
expect(decryptSpy).not.toHaveBeenCalled()
|
||||
|
||||
globalThis.fetch = vi.fn(async () => Response.json({ values: [] })) as unknown as typeof fetch
|
||||
await client.getBitbucketPullRequest('/repo', 1, null)
|
||||
expect(decryptSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('revalidates a stored credential once its secret is already warm in memory', async () => {
|
||||
const { store, client } = await loadModules()
|
||||
store.saveBitbucketCredential(STORED_BASIC_CREDENTIAL)
|
||||
decryptSpy.mockClear()
|
||||
|
||||
// Saving leaves the secret cached, so status can check /user for free.
|
||||
const fetchSpy = vi.fn(async () => new Response(null, { status: 401 }))
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch
|
||||
|
||||
await expect(client.getBitbucketAuthStatus()).resolves.toEqual({
|
||||
configured: true,
|
||||
authenticated: false,
|
||||
account: 'ada'
|
||||
})
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
expect(decryptSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { authHeaders, type BitbucketAuthConfig } from './bitbucket-auth-config'
|
||||
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
|
||||
|
||||
const USER_REQUEST_TIMEOUT_MS = 4000
|
||||
|
||||
export type RawBitbucketUser = {
|
||||
username?: string | null
|
||||
display_name?: string | null
|
||||
account_id?: string | null
|
||||
}
|
||||
|
||||
export function accountNameFromUser(user: RawBitbucketUser | null): string | null {
|
||||
return user?.username ?? user?.display_name ?? user?.account_id ?? null
|
||||
}
|
||||
|
||||
// Shared by live env-var status checks and by connect-time verification, so a
|
||||
// credential is proven against `/user` before it is ever persisted.
|
||||
export async function fetchBitbucketUser(
|
||||
config: BitbucketAuthConfig,
|
||||
timeoutMs: number = USER_REQUEST_TIMEOUT_MS
|
||||
): Promise<RawBitbucketUser | null> {
|
||||
try {
|
||||
const base = config.baseUrl.replace(/\/+$/, '')
|
||||
const response = await fetch(`${base}/user`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders(config)
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
})
|
||||
if (!response.ok) {
|
||||
await cancelUnreadResponseBody(response)
|
||||
return null
|
||||
}
|
||||
return (await response.json()) as RawBitbucketUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
import { measureClipboardTextByteLength } from '../../shared/clipboard-text'
|
||||
import { yieldToEventLoop } from '../../shared/event-loop-yield'
|
||||
import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits'
|
||||
import type { CdpCommandSender } from './snapshot-engine'
|
||||
|
||||
export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
function getUtf8ByteLengthForCodePoint(codePoint: number): number {
|
||||
if (codePoint <= 0x7f) {
|
||||
return 1
|
||||
}
|
||||
if (codePoint <= 0x7ff) {
|
||||
return 2
|
||||
}
|
||||
if (codePoint <= 0xffff) {
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
export function splitBrowserTextInsertionChunks(
|
||||
text: string,
|
||||
maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES
|
||||
@@ -38,24 +26,11 @@ export function* iterateBrowserTextInsertionChunks(
|
||||
return
|
||||
}
|
||||
|
||||
let currentStart = 0
|
||||
let currentBytes = 0
|
||||
let index = 0
|
||||
while (index < text.length) {
|
||||
const codePoint = text.codePointAt(index) ?? 0
|
||||
const codeUnitLength = codePoint > 0xffff ? 2 : 1
|
||||
const nextIndex = index + codeUnitLength
|
||||
const characterBytes = getUtf8ByteLengthForCodePoint(codePoint)
|
||||
if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) {
|
||||
yield text.slice(currentStart, index)
|
||||
currentStart = index
|
||||
currentBytes = 0
|
||||
}
|
||||
currentBytes += characterBytes
|
||||
index = nextIndex
|
||||
}
|
||||
if (currentStart < text.length) {
|
||||
yield text.slice(currentStart)
|
||||
let startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax)
|
||||
yield text.slice(startIndex, endIndex)
|
||||
startIndex = endIndex
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +43,7 @@ export async function insertTextThroughCdp(
|
||||
let chunk = chunks.next()
|
||||
while (!chunk.done) {
|
||||
await sender('Input.insertText', { text: chunk.value })
|
||||
// Why: browser automation text can be paste-sized; yielding keeps the main
|
||||
// process responsive between bounded CDP payloads.
|
||||
// Keep paste-sized insertion from monopolizing the main process.
|
||||
chunk = chunks.next()
|
||||
if (options?.yieldBetweenChunks !== false && !chunk.done) {
|
||||
await yieldToEventLoop()
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
import type * as Scanner from './scanner'
|
||||
|
||||
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
|
||||
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
|
||||
// every open would hide a regression back to multiple full-cache rewrites per scan.
|
||||
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
|
||||
writeGate: {
|
||||
blocked: false,
|
||||
waiters: [] as (() => void)[]
|
||||
}
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -23,29 +15,6 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
open: (async (...args: Parameters<typeof actual.open>) => {
|
||||
if (args[1] !== 'w') {
|
||||
return actual.open(...args)
|
||||
}
|
||||
writeOpens.value += 1
|
||||
writeOpens.inFlight += 1
|
||||
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
|
||||
try {
|
||||
if (writeGate.blocked) {
|
||||
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
|
||||
}
|
||||
return await actual.open(...args)
|
||||
} finally {
|
||||
writeOpens.inFlight -= 1
|
||||
}
|
||||
}) as typeof actual.open
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./scanner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof Scanner>()),
|
||||
scanClaudeUsageFiles: vi.fn()
|
||||
@@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({
|
||||
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
|
||||
import { scanClaudeUsageFiles } from './scanner'
|
||||
|
||||
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
|
||||
const store = new ClaudeUsageStore({
|
||||
function createBackingStore(): ConstructorParameters<typeof ClaudeUsageStore>[0] {
|
||||
return {
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined
|
||||
} as never)
|
||||
getAllWorktreeMeta: () => ({})
|
||||
}
|
||||
}
|
||||
|
||||
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
|
||||
const store = new ClaudeUsageStore(createBackingStore())
|
||||
|
||||
;(store as unknown as { state: ClaudeUsagePersistedState }).state = {
|
||||
schemaVersion: 1,
|
||||
@@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => {
|
||||
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
|
||||
getPathMock.mockReturnValue(tempUserData)
|
||||
initClaudeUsagePath()
|
||||
writeOpens.value = 0
|
||||
writeOpens.inFlight = 0
|
||||
writeOpens.maxConcurrent = 0
|
||||
writeGate.blocked = false
|
||||
writeGate.waiters = []
|
||||
vi.mocked(scanClaudeUsageFiles).mockReset()
|
||||
vi.mocked(scanClaudeUsageFiles).mockResolvedValue({
|
||||
processedFiles: [],
|
||||
@@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => {
|
||||
rmSync(tempUserData, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('defaults a null legacy opt-in while invalidating the cache', () => {
|
||||
writeFileSync(
|
||||
join(tempUserData, 'orca-claude-usage.json'),
|
||||
JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } })
|
||||
)
|
||||
|
||||
const store = new ClaudeUsageStore(createBackingStore())
|
||||
|
||||
expect(store.getScanState().enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca usage exists', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
@@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => {
|
||||
expect(refreshMock).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('persists setEnabled via async durable write without leaving tmp files', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
|
||||
await store.setEnabled(true)
|
||||
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')
|
||||
)
|
||||
expect(persisted.scanState.enabled).toBe(true)
|
||||
// Pretty-print preserved for human inspection of the analytics cache.
|
||||
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
|
||||
})
|
||||
|
||||
it('vetoes a stale concurrent async write so the newer snapshot wins', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
const internals = store as unknown as {
|
||||
writeToDisk: () => Promise<void>
|
||||
state: ClaudeUsagePersistedState
|
||||
}
|
||||
|
||||
writeGate.blocked = true
|
||||
const first = internals.writeToDisk()
|
||||
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
|
||||
|
||||
internals.state.scanState.enabled = false
|
||||
writeGate.blocked = false
|
||||
const second = internals.writeToDisk()
|
||||
writeGate.waiters.splice(0).forEach((resolve) => resolve())
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
|
||||
.enabled
|
||||
).toBe(false)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
|
||||
expect(writeOpens.maxConcurrent).toBe(1)
|
||||
})
|
||||
|
||||
it('persists a successful refresh with one full-cache write', async () => {
|
||||
it('adapts Claude scans to pretty-printed cache persistence', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 5,
|
||||
scanState: {
|
||||
@@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => {
|
||||
|
||||
await store.refresh(true)
|
||||
|
||||
// Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed.
|
||||
expect(writeOpens.value).toBe(1)
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
|
||||
).toMatchObject({
|
||||
lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
|
||||
lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
|
||||
lastScanError: null
|
||||
})
|
||||
})
|
||||
|
||||
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
|
||||
const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp')
|
||||
writeFileSync(orphan, '{}')
|
||||
|
||||
createStoreWithState({})
|
||||
await vi.waitFor(() =>
|
||||
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
)
|
||||
expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], [])
|
||||
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
|
||||
})
|
||||
})
|
||||
|
||||
+37
-187
@@ -1,14 +1,11 @@
|
||||
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
|
||||
/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */
|
||||
import { app } from 'electron'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
|
||||
import type {
|
||||
ClaudeUsageBreakdownKind,
|
||||
ClaudeUsageBreakdownRow,
|
||||
ClaudeUsageDailyPoint,
|
||||
ClaudeUsageRange,
|
||||
ClaudeUsageScanState,
|
||||
ClaudeUsageScope,
|
||||
ClaudeUsageSessionRow,
|
||||
ClaudeUsageSnapshot,
|
||||
@@ -16,16 +13,15 @@ import type {
|
||||
} from '../../shared/claude-usage-types'
|
||||
import type { AutomationRunUsage } from '../../shared/automations-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata'
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
import { createWorktreeRefs } from '../usage/usage-worktree-refs'
|
||||
import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
|
||||
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
|
||||
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
|
||||
|
||||
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
|
||||
// caches either lack ownership or used narrower keys and can under/over-count
|
||||
// after fork reclaim (#8006).
|
||||
const SCHEMA_VERSION = 5
|
||||
const STALE_MS = 5 * 60_000
|
||||
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
|
||||
|
||||
// Why: capture the path after configureDevUserDataPath() but before app.setName()
|
||||
@@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState {
|
||||
if (state.schemaVersion === SCHEMA_VERSION) {
|
||||
return state
|
||||
}
|
||||
// Scanner changes invalidate totals, but preserving enabled keeps existing tracking on.
|
||||
const defaults = getDefaultState()
|
||||
return {
|
||||
...defaults,
|
||||
scanState: {
|
||||
...defaults.scanState,
|
||||
enabled: state.scanState.enabled ?? defaults.scanState.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initClaudeUsagePath(): void {
|
||||
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
|
||||
}
|
||||
@@ -288,120 +299,22 @@ function estimateCostUsd(
|
||||
)
|
||||
}
|
||||
|
||||
function getRangeCutoff(range: ClaudeUsageRange): string | null {
|
||||
if (range === 'all') {
|
||||
return null
|
||||
}
|
||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
now.setDate(now.getDate() - (days - 1))
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getLocalDay(timestamp: string): string | null {
|
||||
const parsed = new Date(timestamp)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
const year = parsed.getFullYear()
|
||||
const month = String(parsed.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(parsed.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getWorktreeFingerprint(worktreesByRepo: Map<string, UsageWorktreeRef[]>): string {
|
||||
const rows = [...worktreesByRepo.entries()]
|
||||
.flatMap(([repoId, worktrees]) =>
|
||||
worktrees.map((worktree) =>
|
||||
JSON.stringify({
|
||||
repoId,
|
||||
worktreeId: worktree.worktreeId,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName
|
||||
})
|
||||
)
|
||||
)
|
||||
.sort()
|
||||
return JSON.stringify(rows)
|
||||
}
|
||||
|
||||
export class ClaudeUsageStore {
|
||||
private state: ClaudeUsagePersistedState
|
||||
private readonly store: Store
|
||||
private scanPromise: Promise<void> | null = null
|
||||
// Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes
|
||||
// and vetoes superseded renames.
|
||||
private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile)
|
||||
|
||||
constructor(store: Store) {
|
||||
this.store = store
|
||||
this.state = this.load()
|
||||
}
|
||||
|
||||
private load(): ClaudeUsagePersistedState {
|
||||
try {
|
||||
const usageFile = getClaudeUsageFile()
|
||||
if (!existsSync(usageFile)) {
|
||||
return getDefaultState()
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState
|
||||
if (parsed.schemaVersion !== SCHEMA_VERSION) {
|
||||
// Why: scanner semantics affect persisted totals, so old Claude caches
|
||||
// must be rebuilt after parser/source changes instead of reused briefly.
|
||||
// Preserve scanState.enabled so existing users keep tracking on across
|
||||
// schema bumps; the next refresh will repopulate the analytics.
|
||||
const defaults = getDefaultState()
|
||||
return {
|
||||
...defaults,
|
||||
scanState: {
|
||||
...defaults.scanState,
|
||||
enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...getDefaultState(),
|
||||
...parsed,
|
||||
scanState: {
|
||||
...getDefaultState().scanState,
|
||||
...parsed.scanState
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: Claude usage is a local analytics feature, not primary workspace
|
||||
// state. A corrupt cache should degrade to a fresh rebuild instead of
|
||||
// preventing Orca from booting, but we leave the file on disk for debugging.
|
||||
console.error('[claude-usage] Failed to load persisted state, starting fresh:', error)
|
||||
return getDefaultState()
|
||||
}
|
||||
}
|
||||
|
||||
private writeToDisk(): Promise<void> {
|
||||
// Pretty-print preserved: humans inspect this analytics cache on disk.
|
||||
return this.writer.write(() => JSON.stringify(this.state, null, 2))
|
||||
}
|
||||
|
||||
/** Await queued cache writes so quit does not drop the final snapshot. */
|
||||
flush(): Promise<void> {
|
||||
return this.writer.flush()
|
||||
}
|
||||
|
||||
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
|
||||
this.state.scanState.enabled = enabled
|
||||
await this.writeToDisk()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
getScanState(): ClaudeUsageScanState {
|
||||
return {
|
||||
...this.state.scanState,
|
||||
isScanning: this.scanPromise !== null,
|
||||
hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0
|
||||
}
|
||||
export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
|
||||
'processedFiles',
|
||||
ClaudeUsagePersistedState,
|
||||
'hasAnyClaudeData'
|
||||
> {
|
||||
constructor(store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
|
||||
super(store, {
|
||||
logTag: '[claude-usage]',
|
||||
resolveCacheFile: getClaudeUsageFile,
|
||||
createDefaultState: getDefaultState,
|
||||
normalizeState: normalizePersistedState,
|
||||
sourceKey: 'processedFiles',
|
||||
dataPresenceKey: 'hasAnyClaudeData',
|
||||
jsonIndent: 2,
|
||||
scan: scanClaudeUsageFiles
|
||||
})
|
||||
}
|
||||
|
||||
getSnapshot(
|
||||
@@ -419,63 +332,6 @@ export class ClaudeUsageStore {
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(force = false): Promise<ClaudeUsageScanState> {
|
||||
if (!this.state.scanState.enabled) {
|
||||
return this.getScanState()
|
||||
}
|
||||
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
|
||||
if (!force && this.state.scanState.lastScanCompletedAt) {
|
||||
const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt
|
||||
if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) {
|
||||
return this.getScanState()
|
||||
}
|
||||
}
|
||||
await this.runScan()
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
private async runScan(): Promise<void> {
|
||||
if (this.scanPromise) {
|
||||
await this.scanPromise
|
||||
return
|
||||
}
|
||||
|
||||
this.state.scanState.lastScanStartedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
|
||||
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
|
||||
// result changed. The completion/failure write below persists the same fields.
|
||||
|
||||
// Why: assign scanPromise before any await so concurrent refresh shares one scan.
|
||||
this.scanPromise = (async () => {
|
||||
try {
|
||||
const repos = this.store.getRepos()
|
||||
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
|
||||
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
|
||||
const result = await scanClaudeUsageFiles(
|
||||
createWorktreeRefs(repos, worktreesByRepo),
|
||||
this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : []
|
||||
)
|
||||
this.state.processedFiles = result.processedFiles
|
||||
this.state.sessions = result.sessions
|
||||
this.state.dailyAggregates = result.dailyAggregates
|
||||
this.state.worktreeFingerprint = worktreeFingerprint
|
||||
this.state.scanState.lastScanCompletedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
|
||||
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} catch (error) {
|
||||
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} finally {
|
||||
this.scanPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
await this.scanPromise
|
||||
}
|
||||
|
||||
async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise<ClaudeUsageSummary> {
|
||||
await this.refresh(false)
|
||||
return this.buildSummary(scope, range)
|
||||
@@ -843,7 +699,7 @@ export class ClaudeUsageStore {
|
||||
}
|
||||
|
||||
private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
|
||||
const cutoff = getRangeCutoff(range)
|
||||
const cutoff = getUsageRangeCutoff(range)
|
||||
return this.state.dailyAggregates.filter((entry) => {
|
||||
if (cutoff && entry.day < cutoff) {
|
||||
return false
|
||||
@@ -856,12 +712,12 @@ export class ClaudeUsageStore {
|
||||
}
|
||||
|
||||
private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
|
||||
const cutoff = getRangeCutoff(range)
|
||||
const cutoff = getUsageRangeCutoff(range)
|
||||
return this.state.sessions.filter((session) => {
|
||||
// Why: daily aggregates use local calendar days, so session filtering has
|
||||
// to use the same conversion or the sessions table/counts can disagree
|
||||
// with the chart around UTC day boundaries.
|
||||
const day = getLocalDay(session.lastTimestamp)
|
||||
const day = getLocalUsageDay(session.lastTimestamp)
|
||||
if (!day) {
|
||||
return false
|
||||
}
|
||||
@@ -883,10 +739,4 @@ export class ClaudeUsageStore {
|
||||
Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
|
||||
)
|
||||
}
|
||||
|
||||
private async getCurrentWorktreeFingerprint(): Promise<string> {
|
||||
const repos = this.store.getRepos()
|
||||
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
|
||||
return getWorktreeFingerprint(worktreesByRepo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,34 @@ vi.mock('electron', () => ({
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
|
||||
import { ClaudeHookService } from './hook-service'
|
||||
import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
|
||||
import { getWindowsManagedLifecycleHook, OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
|
||||
|
||||
const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
|
||||
const STATUSLINE_SCRIPT_FILE_NAME =
|
||||
process.platform === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh'
|
||||
const OPENCLAUDE_SCRIPT_FILE_NAME =
|
||||
process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh'
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
const isClaudeManagedCommand = createManagedCommandMatcher(CLAUDE_SCRIPT_FILE_NAME)
|
||||
const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT_FILE_NAME)
|
||||
|
||||
type TestHook = { command: string; args?: string[] }
|
||||
|
||||
function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined) => boolean) {
|
||||
return matcher(hook.command) || hook.args?.some(matcher) === true
|
||||
}
|
||||
|
||||
describe('getWindowsManagedLifecycleHook', () => {
|
||||
it('resolves the managed script from the runtime Windows profile', () => {
|
||||
const scriptPath = 'C:\\Users\\%name%\\a^b&c\\.orca\\agent-hooks\\claude-hook.cmd'
|
||||
const hook = getWindowsManagedLifecycleHook(scriptPath)
|
||||
|
||||
expect(hook.args?.[0]).toBe('--headless')
|
||||
expect(hook.args?.[1]).toMatch(/\\System32\\cmd\.exe$/i)
|
||||
expect(hook.args?.at(-1)).toBe('%USERPROFILE%\\.orca\\agent-hooks\\claude-hook.cmd')
|
||||
expect(hook.args).not.toContain(scriptPath)
|
||||
})
|
||||
})
|
||||
|
||||
type FakeFs = {
|
||||
files: Map<string, string>
|
||||
dirs: Set<string>
|
||||
@@ -162,18 +178,27 @@ describe('ClaudeHookService.install', () => {
|
||||
AWS_REGION: 'us-west-2'
|
||||
}
|
||||
})
|
||||
const legacyCommands = legacy.hooks.Stop.flatMap(
|
||||
(definition: { hooks: { command: string }[] }) =>
|
||||
definition.hooks.map((hook) => hook.command)
|
||||
const legacyHooks = legacy.hooks.Stop.flatMap(
|
||||
(definition: { hooks: TestHook[] }) => definition.hooks
|
||||
)
|
||||
expect(legacyCommands).toContain('/usr/local/bin/user-hook')
|
||||
expect(legacyCommands.some((command: string) => isClaudeManagedCommand(command))).toBe(true)
|
||||
expect(legacyHooks.map((hook: TestHook) => hook.command)).toContain(
|
||||
'/usr/local/bin/user-hook'
|
||||
)
|
||||
const managedHook = legacyHooks.find((hook: TestHook) =>
|
||||
hasManagedCommand(hook, isClaudeManagedCommand)
|
||||
)
|
||||
expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
expect(
|
||||
legacyCommands.some((command: string) =>
|
||||
command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
|
||||
legacyHooks.some((hook: TestHook) => hasManagedCommand(hook, isClaudeManagedCommand))
|
||||
).toBe(true)
|
||||
expect(
|
||||
legacyHooks.some((hook: TestHook) =>
|
||||
hook.command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(isClaudeManagedCommand(legacy.hooks.StopFailure[0].hooks[0].command)).toBe(true)
|
||||
expect(hasManagedCommand(legacy.hooks.StopFailure[0].hooks[0], isClaudeManagedCommand)).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8')
|
||||
).toContain('DEVIN_PROJECT_DIR')
|
||||
@@ -194,7 +219,13 @@ describe('ClaudeHookService.install', () => {
|
||||
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
|
||||
) as { statusLine?: { type: string; command: string } }
|
||||
expect(settings.statusLine?.type).toBe('command')
|
||||
expect(settings.statusLine?.command).toContain('claude-statusline')
|
||||
expect(settings.statusLine?.command).toContain(
|
||||
'"$HOME/.orca/agent-hooks/claude-statusline.cmd"'
|
||||
)
|
||||
expect(settings.statusLine?.command).toContain(
|
||||
'"$HOME/.orca/agent-hooks/claude-statusline.sh"'
|
||||
)
|
||||
expect(settings.statusLine?.command).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
|
||||
const script = readFileSync(
|
||||
join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME),
|
||||
@@ -307,12 +338,8 @@ describe('ClaudeHookService.install', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path
|
||||
// with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed
|
||||
// command must use an encoded launcher so Git Bash/cmd.exe never splits or
|
||||
// expands the raw path before invoking the managed .cmd.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command to survive spaces in the profile path (#6078)',
|
||||
'runs portable managed hooks through headless exec form',
|
||||
() => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'orca claude home with spaces '))
|
||||
vi.stubEnv('HOME', tmpHome)
|
||||
@@ -322,11 +349,26 @@ describe('ClaudeHookService.install', () => {
|
||||
|
||||
const settings = JSON.parse(
|
||||
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
|
||||
) as { hooks: Record<string, { hooks: { command: string }[] }[]> }
|
||||
) as { hooks: Record<string, { hooks: TestHook[] }[]> }
|
||||
|
||||
const system32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
||||
const scriptPath = join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME)
|
||||
const runtimeScriptPath = join(
|
||||
'%USERPROFILE%',
|
||||
'.orca',
|
||||
'agent-hooks',
|
||||
CLAUDE_SCRIPT_FILE_NAME
|
||||
)
|
||||
|
||||
for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const command = settings.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
|
||||
const hook = settings.hooks[eventName]?.[0]?.hooks?.[0]
|
||||
expect(hook).toEqual({
|
||||
type: 'command',
|
||||
command: join(system32, 'conhost.exe'),
|
||||
args: ['--headless', join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath],
|
||||
timeout: 10
|
||||
})
|
||||
expect(hook.args).not.toContain(scriptPath)
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
@@ -335,9 +377,6 @@ describe('ClaudeHookService.install', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Why: the launcher must stay PowerShell-encoded for Git Bash, but the hook
|
||||
// POST inside the .cmd should use curl.exe so each hook spawns one
|
||||
// interpreter, not two. Posting via a second PowerShell was the slow path.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'posts from the managed .cmd via curl.exe, not a second PowerShell',
|
||||
() => {
|
||||
@@ -372,11 +411,7 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
const settings = fs.files.get('/home/dev/.claude/settings.json')
|
||||
expect(settings).toBeTruthy()
|
||||
const parsed = JSON.parse(settings!)
|
||||
// Why: every load-bearing event must be present and point at the
|
||||
// remote-shaped script path with the guarded launcher applied. Drift in
|
||||
// any of these is a real bug — Claude
|
||||
// Code rejects unknown shapes silently and the agent-hooks pipeline
|
||||
// goes dark.
|
||||
// Why: Claude silently rejects drifted hook shapes, so assert every load-bearing event.
|
||||
for (const event of [
|
||||
// Why: SessionStart is the only signal a resumed/idle session ever emits;
|
||||
// without it the sidebar row waits for the first prompt (STA-3386).
|
||||
@@ -394,8 +429,8 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
]) {
|
||||
expect(parsed.hooks[event]).toBeTruthy()
|
||||
const cmd = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
expect(cmd).toMatch(/^if \[ -f /)
|
||||
expect(cmd).toContain('"$HOME/.orca/agent-hooks/claude-hook.sh"')
|
||||
expect(cmd).not.toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
}
|
||||
// Managed script body
|
||||
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
|
||||
@@ -487,9 +522,9 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
|
||||
const command = parsed.hooks[event][0].hooks[0].command as string
|
||||
expect(isOpenClaudeManagedCommand(command)).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(command).toMatch(/^if \[ -f /)
|
||||
}
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.cmd"')
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"')
|
||||
expect(command).not.toContain(tmpHome.replaceAll('\\', '/'))
|
||||
}
|
||||
expect(
|
||||
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
|
||||
@@ -518,7 +553,8 @@ describe('OpenClaudeHookService-compatible install', () => {
|
||||
})
|
||||
const parsed = JSON.parse(fs.files.get('/home/dev/.openclaude/settings.json')!)
|
||||
const command = parsed.hooks.StopFailure[0].hooks[0].command as string
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh')
|
||||
expect(command).toContain('"$HOME/.orca/agent-hooks/openclaude-hook.sh"')
|
||||
expect(command).not.toContain('/home/dev/.orca/agent-hooks/openclaude-hook.sh')
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/openclaude-hook.sh')).toContain('/hook/claude')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
buildManagedCommandHook,
|
||||
buildWindowsAgentHookCurlPostCommand,
|
||||
readHooksJson,
|
||||
writeHooksJson,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
getManagedScriptFileName,
|
||||
getConfigPath,
|
||||
getManagedCommand,
|
||||
getManagedLifecycleHook,
|
||||
getManagedScriptPath,
|
||||
getPosixManagedScriptFileName,
|
||||
getRemoteConfigPath,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
getStatusLineScriptFileName,
|
||||
getStatusLineScriptPath,
|
||||
getStatusLineSlotState,
|
||||
hasSameManagedHookInvocation,
|
||||
removeManagedHooks,
|
||||
removeManagedStatusLine,
|
||||
type ClaudeCompatibleHookSettings
|
||||
@@ -140,7 +143,7 @@ export class ClaudeHookService {
|
||||
}
|
||||
|
||||
// Why: report partial registration instead of a false installed state.
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of CLAUDE_EVENTS) {
|
||||
@@ -148,7 +151,7 @@ export class ClaudeHookService {
|
||||
? config.hooks![event.eventName]!
|
||||
: []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
(definition.hooks ?? []).some((hook) => hasSameManagedHookInvocation(hook, expectedHook))
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
@@ -198,10 +201,10 @@ export class ClaudeHookService {
|
||||
}
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const hook = getManagedLifecycleHook(scriptPath, this.options.settings)
|
||||
let nextConfig = applyManagedHooks(
|
||||
config,
|
||||
command,
|
||||
hook,
|
||||
getManagedScriptFileName(this.options.settings)
|
||||
)
|
||||
writeManagedScript(
|
||||
@@ -259,9 +262,9 @@ export class ClaudeHookService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the POSIX wrapper is identical regardless of where the script lands; only the path differs.
|
||||
const command = getRemoteManagedCommand(remoteScriptPath)
|
||||
const nextConfig = applyManagedHooks(config, command, remoteScriptFileName)
|
||||
// Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home.
|
||||
const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath))
|
||||
const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName)
|
||||
|
||||
// Why: write scripts before settings to avoid settings pointing to missing scripts.
|
||||
// Why: SSH scripts always use POSIX .sh paths, regardless of the local OS.
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { basename, extname, join, win32 } from 'node:path'
|
||||
import {
|
||||
buildManagedCommandHook,
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
isPlainObject,
|
||||
MANAGED_HOOK_TIMEOUT_SECONDS,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsGitBashHookCommand,
|
||||
type HookCommandConfig,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command'
|
||||
|
||||
export type ClaudeCompatibleHookSettings = {
|
||||
configDirName: '.claude' | '.openclaude'
|
||||
scriptBaseName: 'claude-hook' | 'openclaude-hook'
|
||||
supportsExecHookArgs: boolean
|
||||
}
|
||||
|
||||
export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
|
||||
configDirName: '.claude',
|
||||
scriptBaseName: 'claude-hook'
|
||||
scriptBaseName: 'claude-hook',
|
||||
supportsExecHookArgs: true
|
||||
}
|
||||
|
||||
export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = {
|
||||
configDirName: '.openclaude',
|
||||
scriptBaseName: 'openclaude-hook'
|
||||
scriptBaseName: 'openclaude-hook',
|
||||
supportsExecHookArgs: false
|
||||
}
|
||||
|
||||
export const CLAUDE_EVENTS = [
|
||||
@@ -105,18 +109,57 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
|
||||
}
|
||||
|
||||
export function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32'
|
||||
? wrapWindowsGitBashHookCommand(scriptPath)
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
const scriptFileName = basename(scriptPath)
|
||||
const extension = extname(scriptFileName)
|
||||
return wrapRuntimeHomeHookCommand(
|
||||
extension ? scriptFileName.slice(0, -extension.length) : scriptFileName
|
||||
)
|
||||
}
|
||||
|
||||
export function getManagedLifecycleHook(
|
||||
scriptPath: string,
|
||||
settings = CLAUDE_HOOK_SETTINGS
|
||||
): HookCommandConfig {
|
||||
if (process.platform !== 'win32' || !settings.supportsExecHookArgs) {
|
||||
return buildManagedCommandHook(getManagedCommand(scriptPath))
|
||||
}
|
||||
return getWindowsManagedLifecycleHook(scriptPath)
|
||||
}
|
||||
|
||||
export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig {
|
||||
const system32 = win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
||||
const runtimeScriptPath = win32.join(
|
||||
'%USERPROFILE%',
|
||||
'.orca',
|
||||
'agent-hooks',
|
||||
win32.basename(scriptPath)
|
||||
)
|
||||
// Why: Claude's Windows shell form opens Git Bash consoles; exec form hosts the client in a windowless console.
|
||||
return {
|
||||
type: 'command',
|
||||
command: win32.join(system32, 'conhost.exe'),
|
||||
args: ['--headless', win32.join(system32, 'cmd.exe'), '/d', '/c', runtimeScriptPath],
|
||||
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSameManagedHookInvocation(
|
||||
actual: HookCommandConfig,
|
||||
expected: HookCommandConfig
|
||||
): boolean {
|
||||
return (
|
||||
actual.command === expected.command &&
|
||||
JSON.stringify(actual.args ?? []) === JSON.stringify(expected.args ?? [])
|
||||
)
|
||||
}
|
||||
|
||||
export function getRemoteManagedCommand(scriptPath: string): string {
|
||||
return wrapPosixHookCommand(scriptPath)
|
||||
return getManagedCommand(scriptPath)
|
||||
}
|
||||
|
||||
export function applyManagedHooks(
|
||||
config: HooksConfig,
|
||||
command: string,
|
||||
hook: HookCommandConfig,
|
||||
scriptFileName = getManagedScriptFileName()
|
||||
): HooksConfig {
|
||||
const nextHooks = { ...config.hooks }
|
||||
@@ -127,7 +170,7 @@ export function applyManagedHooks(
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
const definition: HookDefinition = {
|
||||
...event.definition,
|
||||
hooks: [buildManagedCommandHook(command)]
|
||||
hooks: [hook]
|
||||
}
|
||||
nextHooks[event.eventName] = [...cleaned, definition]
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ describe('CliInstaller', () => {
|
||||
const registryReader = new WindowsUserPathRegistryReader({
|
||||
platform: 'win32',
|
||||
registryLoader: async () => ({
|
||||
HK: { CU: 0x80000001 },
|
||||
HK: { CU: 0x80000001, LM: 0x80000002 },
|
||||
getRegistryKey: () => ({
|
||||
Path: { name: 'Path', type: 2, value: registryPath }
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user