diff --git a/docs/TESTING.md b/docs/TESTING.md index 9b4be8b1..98c16366 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -44,6 +44,43 @@ npx playwright test e2e/packaged-smoke.spec.ts --reporter=list,json node scripts/verify-smoke-report.mjs /absolute/path/to/smoke.json ``` +## Local iteration and process reuse + +Run the affected fast tests while editing. Run the ordered full gates once on +one coherent implementation candidate, before submission; a later docs-only or +test-only cleanup does not require repeatedly running every shell benchmark. +A new failure or product change is a reason to rerun its affected checks. + +Use one Electron process for a normal user journey, with a fresh project and +reset preferences/viewport for independent settings cases. Keep separate +processes for replaced IPC handlers, machine-wide plugin mutations, updater +restart tests and actual startup/installation differences. Reusing those blindly +would trade desktop interruptions for contaminated results. + +For settings → source connection → export/recovery, select just: + +```sh +REDLOG_PACKAGED_APP='/absolute/path/to/RedLog' \ +npx playwright test e2e/capture-settings-interaction.spec.ts e2e/export-preview.spec.ts --reporter=line +``` + +This selection has 14 behavioral cases in four app processes when all pass: +one shared settings process, two isolated fault/integration cases, and one +source-to-export journey. Do not also run a separate source-recovery spec or +repeat the same unchanged journeys against both source and package. Use source +while no package exists; prefer the package for final acceptance. The external +Zsh step is POSIX-only; a skip never counts as Windows shell verification. + +Renderer tests own draft state, stale replies, policy controls and messages; +storage tests own atomic receipt writes and project isolation. E2E retains only +cross-layer recording/delivery, failure recovery and actual layout/focus behavior. +Do not add another test whose only assertion is that an already-exercised +control exists. Native manual checks are for remaining OS-specific interactions, +not another replay of every passed CDP journey. Non-HUD shared-helper +launches keep the HUD hidden in the test process without changing its product +or project default. HUD-specific tests request `showHud: true`; a hidden HUD +fixture does not verify default HUD visibility. + ## Coverage owners | Requirement | Primary tests | diff --git a/docs/UIUX-STANDARD.md b/docs/UIUX-STANDARD.md index a7bd9615..4dcd4f01 100644 --- a/docs/UIUX-STANDARD.md +++ b/docs/UIUX-STANDARD.md @@ -186,7 +186,7 @@ ## 9. 錯誤、狀態與資料量 - **錯誤訊息三段式**:發生什麼事(中文)+ 為什麼 + 一顆動作;原始錯誤收在「詳細」折疊區 -- **Toast**:錯誤不自動消失;同類合併計數;上限 3 則;容器需 `aria-live="polite"`(錯誤用 `assertive`) +- **Toast**:錯誤不因逾時自動消失;同一操作確認重試成功後,移除對應錯誤,不清掉其他操作或專案的通知;同類合併計數;上限 3 則;容器需 `aria-live="polite"`(錯誤用 `assertive`) - **持續性故障**分兩層固定在狀態列左側:〈需要注意 N〉只收影響證據的(抓取中斷、錨定失敗、鏈斷、DB 寫入錯誤、檔案遺失)—— **不可忽略**、紅點;其餘收〈待處理〉—— 可忽略、灰色、不寫稽核 - **範圍違規不進〈需要注意〉**。它不是故障,也可能是刻意的;側欄徽章與狀態列已經夠明顯。〈需要注意〉只收「系統沒在正常運作」 - **時間**:一律 24 小時。相對時間**只用於新鮮度欄位**(最後一次抓取、3 秒前);事件本身、時間軸軸標、逐字稿、匯出一律絕對時間,匯出用 ISO 8601 diff --git a/docs/domain/SPEC-capture-source-lifecycle.md b/docs/domain/SPEC-capture-source-lifecycle.md index 981d57e5..f713b8d7 100644 --- a/docs/domain/SPEC-capture-source-lifecycle.md +++ b/docs/domain/SPEC-capture-source-lifecycle.md @@ -249,3 +249,9 @@ Intact recorded packages can be removed or explicitly updated after an app update; only the current app-supplied bytes may load. Unknown/modified packages are preserved and refused. Updating preserves prior installation identity but does not create or widen project recording permission. + +## Shared recovery entry (2026-10-24, Spec 062) + +Opening source settings only reads machine state. Explicit Connect / Verify / repair opens the same terminal enrollment used by first-run setup; host Bash/Zsh/PowerShell and WSL retain their own installers and nonce identities. An installed profile is not a received command, and a command is not output proof. Only adapters the backend reports usable may be installed; this does not add PowerShell-on-macOS backend support. + +Dependencies are prepared in the selected source page through the existing installer/cancel/progress path. An unknown or failed integration-list read offers retry, never an enabled project switch. Restoring a known disabled bundled integration is a machine action; it does not change the project's pack selection or enable clipboard. Missing/untrusted integrations retain the management route instead of pretending a repair succeeded. External producers retain their own start commands and heartbeat; no project toggle claims to start them. diff --git a/docs/domain/SPEC-export-event-selection.md b/docs/domain/SPEC-export-event-selection.md index b2265779..4e106ed4 100644 --- a/docs/domain/SPEC-export-event-selection.md +++ b/docs/domain/SPEC-export-event-selection.md @@ -188,3 +188,9 @@ original-chain completeness or transformed source bytes from that projection. Checksums and selection declarations do not authenticate source identity against an adversary who replaces both content and metadata. Projection success must state this boundary rather than report the source chain as INTACT. + +## Completed artifact receipt (2026-10-24, Spec 062) + +The export dialog retains the actual successful artifact until explicitly dismissed. A main-owned machine-local receipt permits finding the current project's last successful result again. It contains only path, format and completion time; file availability is rechecked. It is not an Export Selection or a plan and cannot authorize a new export. Failed/canceled attempts never replace it. Metadata persistence failure reports a warning without changing a successfully produced artifact into a failed export. + +The first export defaults to evidence bundle; applicable HTTP views still start with HAR. Explicitly remembered JSON/NDJSON/bundle choices are preserved. Other formats remain selectable without altering the frozen query, scope policy, attachments or snapshot. diff --git a/e2e/README.md b/e2e/README.md index 9e5c0109..aff12246 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -33,6 +33,18 @@ Installed-only runs use the package's own native modules. With `REDLOG_PACKAGED_APP` set, the documented direct Playwright command does not need to rebuild workspace SQLite unless another selected spec launches source. +For the bounded settings/source/export check, use the two-file selection in +[Testing RedLog](../docs/TESTING.md#local-iteration-and-process-reuse). Ordinary +settings cases share an app and use separate projects; `export-preview` includes +source connection before export. IPC fault injection stays isolated. Avoid +running an unchanged journey once for source and again for package by default. + +Non-HUD journeys using `launchWithTempHome` suppress automatic HUD display only +in the E2E process. HUD acceptance opts in with +`launchWithTempHome(executablePath, { showHud: true })`; direct HUD-specific +launches retain their normal display. The app's HUD default stays on and the +project config is not changed by this fixture. + ## What it covers One spec per journey, named for the journey: `first-run`, `project-flow`, diff --git a/e2e/capture-settings-interaction.spec.ts b/e2e/capture-settings-interaction.spec.ts index 73b06ed5..27517798 100644 --- a/e2e/capture-settings-interaction.spec.ts +++ b/e2e/capture-settings-interaction.spec.ts @@ -1,12 +1,27 @@ -import { test, expect } from '@playwright/test' -import { mkdirSync } from 'node:fs' +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' +import { chmodSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { launchWithTempHome, openTestProject, openView, openSettingsPage, resizeMainWindow } from './helpers' -test('project settings stay separate from display preferences and long content fits at large scales', async () => { - test.setTimeout(60_000) - const { app, page } = await launchWithTempHome() - try { +// These cases change ordinary project settings only. Fresh projects plus a +// reset viewport/preferences keep them independent without restarting Electron. +test.describe('settings interaction journey', () => { + let app: ElectronApplication + let page: Page + test.beforeAll(async () => { + const launched = await launchWithTempHome(process.env.REDLOG_PACKAGED_APP) + app = launched.app; page = launched.page + }) + test.beforeEach(async () => { + const win = await app.browserWindow(page) + await win.evaluate(w => w.webContents.setZoomFactor(1)) + await resizeMainWindow(app, 1100, 800) + await page.evaluate(() => localStorage.clear()) + }) + test.afterAll(async () => { if (app) await app.close() }) + + test('project settings stay separate from display preferences and long content fits at large scales', async () => { + test.setTimeout(60_000) await openTestProject(page, 'settings-layout') await page.evaluate(async () => { await window.redlog.httpCapture.stop() @@ -79,12 +94,9 @@ test('project settings stay separate from display preferences and long content f await openSettingsPage(page, 'general') await page.getByRole('button', { name: locale === 'en' ? 'Normal' : '正常', exact: true }).click() } - } finally { await app.close() } -}) + }) -test('plugin settings use the available width without overflowing compact windows', async () => { - const { app, page } = await launchWithTempHome() - try { + test('plugin settings use the available width without overflowing compact windows', async () => { await openTestProject(page, 'plugin-layout') await page.evaluate(() => window.redlog.httpCapture.stop()) await openView(page, 'settings') @@ -100,12 +112,9 @@ test('plugin settings use the available width without overflowing compact window })).toBeLessThanOrEqual(32) expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) } - } finally { await app.close() } -}) + }) -test('port drafts are validated before autosave in English and Traditional Chinese', async () => { - const { app, page } = await launchWithTempHome() - try { + test('port drafts are validated before autosave in English and Traditional Chinese', async () => { await openTestProject(page, 'capture-port-drafts') await page.evaluate(() => window.redlog.httpCapture.stop()) for (const [locale, label, port] of [ @@ -137,11 +146,49 @@ test('port drafts are validated before autosave in English and Traditional Chine await openSettingsPage(page, 'browser') await expect(page.getByLabel(label, { exact: true })).toHaveValue(port) } - } finally { await app.close() } + }) + + test('a successful settings retry clears its stale error after a real write refusal', async () => { + test.skip(process.platform === 'win32', 'POSIX file-mode refusal; Windows is covered by the settings save result tests') + let configPath: string | undefined + let originalMode: number | undefined + try { + const project = await openTestProject(page, 'settings-retry-error') + await page.evaluate(async () => { + localStorage.setItem('redlog-locale', 'en') + await window.redlog.httpCapture.stop() + }) + configPath = join(await page.evaluate(async id => { + const projects = await window.redlog.project.list() + return projects.find(p => p.id === id)!.path + }, project.id), 'config.yaml') + originalMode = statSync(configPath).mode & 0o777 + const before = readFileSync(configPath) + await page.reload() + await openView(page, 'settings') + await openSettingsPage(page, 'scope') + chmodSync(configPath, 0o444) + await page.getByLabel('Name', { exact: true }).nth(1).fill('Recovered operator') + await expect(page.getByTestId('settings-save-failed')).toBeVisible() + await expect(page.getByRole('alert')).toBeVisible() + expect(readFileSync(configPath)).toEqual(before) + chmodSync(configPath, originalMode) + await page.getByRole('button', { name: 'Retry', exact: true }).click() + await expect(page.getByTestId('settings-save-saved')).toBeVisible() + await expect(page.getByRole('alert')).toHaveCount(0) + await expect.poll(async () => (await page.evaluate(() => window.redlog.config.get())).operator.name).toBe('Recovered operator') + await page.reload() + expect((await page.evaluate(() => window.redlog.config.get())).operator.name).toBe('Recovered operator') + } finally { + if (configPath && originalMode !== undefined) chmodSync(configPath, originalMode) + } + }) + }) +// Deliberate isolation: these cases replace IPC or mutate machine integrations. test('external-source running and failed IPC reads remain distinct in settings', async () => { - const { app, page } = await launchWithTempHome() + const { app, page } = await launchWithTempHome(process.env.REDLOG_PACKAGED_APP) try { await openTestProject(page, 'external-source-status') await page.evaluate(() => window.redlog.httpCapture.stop()) @@ -178,7 +225,7 @@ test('external-source running and failed IPC reads remain distinct in settings', }) test('optional AI recovers in its source entry and requires explicit project authorization', async () => { - const { app, page, tmpHome } = await launchWithTempHome() + const { app, page, tmpHome } = await launchWithTempHome(process.env.REDLOG_PACKAGED_APP) try { const first = join(tmpHome, 'client-work') const second = join(tmpHome, 'second-client-work') diff --git a/e2e/export-preview.spec.ts b/e2e/export-preview.spec.ts index c06d60d3..197da5f3 100644 --- a/e2e/export-preview.spec.ts +++ b/e2e/export-preview.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type ElectronApplication, type Page } from '@playwright/test' import { mkdirSync, writeFileSync, readFileSync, chmodSync, statSync, readdirSync, renameSync, existsSync } from 'node:fs' import { join } from 'node:path' +import { checkSourceRecovery } from './source-recovery' import { launchWithTempHome, openTestProject } from './helpers' test.describe.serial('approved export plan', () => { @@ -22,23 +23,54 @@ test.describe.serial('approved export plan', () => { if (app) await app.close() }) + test('connects the external source before handing off its evidence', async () => { + test.skip(process.platform === 'win32', 'POSIX source acceptance; export cases below still run on Windows') + test.setTimeout(60_000) + await checkSourceRecovery(app, page, tmpHome) + }) + test('previews and executes the same approved JSON plan', async () => { - await page.getByLabel('Export').click() + await page.getByRole('button', { name: 'Export', exact: true }).click() await page.getByLabel('Export range', { exact: true }).selectOption('all') await expect(page.getByText('Plan fingerprint')).toBeVisible() await expect(page.getByTestId('export-preview-subset')).toHaveText('Entire approved snapshot') - await expect(page.getByLabel('Export format')).toHaveValue('json') + await expect(page.getByLabel('Export format')).toHaveValue('bundle') + await page.getByLabel('Export format', { exact: true }).selectOption('other') + await page.getByLabel('Other formats', { exact: true }).selectOption('json') const fingerprint = await page.getByText(/^[a-f0-9]{12}$/).textContent() expect(fingerprint).toMatch(/^[a-f0-9]{12}$/) await page.getByTestId('export-confirm').click() - await expect(page.getByText('Exported Everything')).toBeVisible() + await expect(page.getByTestId('export-completed')).toBeVisible() + await page.getByRole('button', { name: 'Close', exact: true }).last().click() + }) + + test('finds the last result after a reload and reports a removed file without losing the result', async () => { + const delivery = await page.evaluate(async () => { + const result = await window.redlog.data.recentExport() + if (!result.ok || !result.delivery) throw new Error('completed result missing') + return result.delivery + }) + await page.reload() + await page.getByLabel('Export', { exact: true }).click() + await page.getByTestId('export-recent').click() + await expect(page.getByTestId('export-completed')).toContainText(delivery.artifactPath) + await page.keyboard.press('Escape') + const held = delivery.artifactPath + '.held' + renameSync(delivery.artifactPath, held) + try { + await page.getByLabel('Export', { exact: true }).click() + await page.getByTestId('export-recent').click() + await expect(page.getByTestId('export-completed')).toContainText('The exported file has been removed') + await expect(page.getByTestId('export-reveal')).toBeDisabled() + await page.keyboard.press('Escape') + } finally { renameSync(held, delivery.artifactPath) } }) // The current export UI is a fixed dialog. Measure its actual viewport // bounds and hit targets; old title-bar dropdown ancestry is no longer its // contract. Use client rects consistently under body zoom. test('keeps the dialog and its actions inside the viewport', async () => { - await page.getByLabel('Export').click() + await page.getByRole('button', { name: 'Export', exact: true }).click() const panel = page.getByTestId('export-dialog') await expect(panel).toBeVisible() // The action bar renders once the preview has loaded, which under a full @@ -104,7 +136,8 @@ test.describe.serial('approved export plan', () => { const filesBefore = readdirSync(exports).sort() const evidenceBefore = await page.evaluate(async () => (await window.redlog.events.query({ limit: -1 })).map(e => e.id)) await page.getByLabel('Export', { exact: true }).click() - await page.getByLabel('Export format', { exact: true }).selectOption('json') + await page.getByLabel('Export format', { exact: true }).selectOption('other') + await page.getByLabel('Other formats', { exact: true }).selectOption('json') await page.getByLabel('Export range', { exact: true }).selectOption('all') await expect(page.getByTestId('export-confirm')).toBeEnabled() try { @@ -113,7 +146,7 @@ test.describe.serial('approved export plan', () => { const error = page.getByTestId('export-run-error') await expect(error).toContainText(/EACCES|permission denied/i) await expect(page.getByTestId('export-dialog')).toBeVisible() - await expect(page.getByLabel('Export format', { exact: true })).toHaveValue('json') + await expect(page.getByLabel('Other formats', { exact: true })).toHaveValue('json') await expect(page.getByLabel('Export range', { exact: true })).toHaveValue('all') expect(readdirSync(exports).sort()).toEqual(filesBefore) chmodSync(exports, mode) @@ -121,7 +154,8 @@ test.describe.serial('approved export plan', () => { await expect(error).toHaveCount(0) await expect(page.getByTestId('export-confirm')).toBeEnabled() await page.getByTestId('export-confirm').click() - await expect(page.getByTestId('export-dialog')).toHaveCount(0) + await expect(page.getByTestId('export-completed')).toBeVisible() + await page.getByRole('button', { name: 'Close', exact: true }).last().click() const newFiles = readdirSync(exports).filter(f => !filesBefore.includes(f) && f.endsWith('.json') && !f.endsWith('.manifest.json')) expect(newFiles).toHaveLength(1) const delivered = JSON.parse(readFileSync(join(exports, newFiles[0]), 'utf8')) diff --git a/e2e/external-session.spec.ts b/e2e/external-session.spec.ts index 71b3fa73..a1d4b524 100644 --- a/e2e/external-session.spec.ts +++ b/e2e/external-session.spec.ts @@ -32,7 +32,8 @@ test('external PTY output remains searchable and exportable while paused', async await page.getByLabel('Export range', { exact: true }).selectOption('all') await expect(page.getByText('Plan fingerprint')).toBeVisible() await page.getByTestId('export-confirm').click() - await expect(page.getByText('Exported Everything')).toBeVisible() + await expect(page.getByTestId('export-completed')).toBeVisible() + await page.getByRole('button', { name: 'Close', exact: true }).last().click() } finally { await app.close() } diff --git a/e2e/filtered-export.spec.ts b/e2e/filtered-export.spec.ts index 3f517fe8..aa5355ee 100644 --- a/e2e/filtered-export.spec.ts +++ b/e2e/filtered-export.spec.ts @@ -26,9 +26,11 @@ for (const locale of ['en', 'zh-TW']) { // Search has a 100-row page. Export must include all 106 matches without // requiring Load more, and switching format must retain that query. const formatLabel=locale==='en'?'Export format':'匯出格式' - await page.getByLabel(formatLabel,{exact:true}).selectOption('ndjson') + await page.getByLabel(formatLabel,{exact:true}).selectOption('other') + const otherLabel=locale==='en'?'Other formats':'其他格式' + await page.getByLabel(otherLabel,{exact:true}).selectOption('ndjson') await expect(page.getByTestId('export-preview-subset')).toContainText('needle') - await page.getByLabel(formatLabel,{exact:true}).selectOption('json') + await page.getByLabel(otherLabel,{exact:true}).selectOption('json') await expect(page.getByTestId('export-projection-notice')).toBeVisible() await expect(page.getByTestId('export-confirm')).toBeEnabled() // A matching event received after the operator saw the preview must not @@ -38,7 +40,8 @@ for (const locale of ['en', 'zh-TW']) { const dialog=page.getByTestId('export-dialog') expect(await dialog.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true) await page.getByTestId('export-confirm').focus(); await page.keyboard.press('Enter') - await expect(dialog).toHaveCount(0) + await expect(page.getByTestId('export-completed')).toBeVisible() + await page.getByRole('button',{name:locale==='en'?'Close':'關閉',exact:true}).last().click() const active=await page.evaluate(async () => { const current=await window.redlog.project.active() return (await window.redlog.project.list()).find(project => project.id===current?.id)! diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 2f14b259..c550062c 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -44,7 +44,7 @@ export function makeTempHome(prefix = 'redlog-e2e-'): string { * test never touches the operator's real `~/.redlog/`. Also asserts the build * artifact exists so the failure message is actionable. */ -export async function launchWithTempHome(executablePath?: string): Promise<{ +export async function launchWithTempHome(executablePath?: string, { showHud = false }: { showHud?: boolean } = {}): Promise<{ app: ElectronApplication page: Page tmpHome: string @@ -69,7 +69,8 @@ export async function launchWithTempHome(executablePath?: string): Promise<{ CODEX_HOME: join(tmpHome, '.codex'), // Enables dev-only IPC endpoints used by E2E specs. The main process // refuses those endpoints when this flag is unset. - REDLOG_E2E: '1' + REDLOG_E2E: '1', + REDLOG_E2E_HIDE_HUD: showHud ? '0' : '1' } }) const page = await app.firstWindow() diff --git a/e2e/hud-overlay.spec.ts b/e2e/hud-overlay.spec.ts index bb245f61..94b046f3 100644 --- a/e2e/hud-overlay.spec.ts +++ b/e2e/hud-overlay.spec.ts @@ -243,7 +243,7 @@ test.describe.serial('HUD size stability', () => { // CDP keyboard bypasses before-input-event, so send through Electron's native // input pipeline instead. Manual acceptance covers actual macOS key delivery. test('the release chord works in main and HUD windows without enabling it again', async () => { - const { app, page } = await launchWithTempHome(process.env.REDLOG_PACKAGED_APP) + const { app, page } = await launchWithTempHome(process.env.REDLOG_PACKAGED_APP, { showHud: true }) try { await openTestProject(page, 'hud-window-release') await expect.poll(() => app.windows().some(w => w.url().includes('overlay'))).toBe(true) diff --git a/e2e/official-agent-capture.spec.ts b/e2e/official-agent-capture.spec.ts index 39016310..0d01b3ed 100644 --- a/e2e/official-agent-capture.spec.ts +++ b/e2e/official-agent-capture.spec.ts @@ -147,10 +147,12 @@ for (const kind of ['claude-code', 'codex'] as const) test(`${kind}: new authori await page.getByLabel('Export', { exact: true }).click() await page.getByLabel('Export range', { exact: true }).selectOption('all') - await page.getByLabel('Export format', { exact: true }).selectOption('json') + await page.getByLabel('Export format', { exact: true }).selectOption('other') + await page.getByLabel('Other formats', { exact: true }).selectOption('json') await expect(page.getByTestId('export-confirm')).toBeEnabled() await page.getByTestId('export-confirm').click() - await expect(page.getByTestId('export-dialog')).toHaveCount(0) + await expect(page.getByTestId('export-completed')).toBeVisible() + await page.getByRole('button', { name: 'Close', exact: true }).last().click() const exports = join(project.path, 'exports') const exported = readdirSync(exports).find(p => p.endsWith('.json') && !p.endsWith('.manifest.json'))! const delivered = JSON.parse(readFileSync(join(exports, exported), 'utf8')) diff --git a/e2e/source-recovery.ts b/e2e/source-recovery.ts new file mode 100644 index 00000000..508ab7de --- /dev/null +++ b/e2e/source-recovery.ts @@ -0,0 +1,42 @@ +import { expect, type ElectronApplication, type Page } from '@playwright/test' +import { readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { openView, openSettingsPage, resizeMainWindow } from './helpers' +import { findShellTarget, runZsh } from '../test/helpers/zsh-pty' + +/** Actual source connection before delivering evidence, in the same app. */ +export async function checkSourceRecovery(app: ElectronApplication, page: Page, tmpHome: string): Promise { + expect(await page.evaluate(async () => (await window.redlog.config.get()).overlay.autoShow)).toBe(true) + expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows() + .filter(w => w.webContents.getURL().includes('overlay')).some(w => w.isVisible()))).toBe(false) + await openView(page, 'settings'); await openSettingsPage(page, 'hooks') + await expect(page.getByTestId('hook-connect-shell-zsh')).toBeVisible() + expect(existsSync(join(tmpHome, '.zshrc'))).toBe(false) + await page.getByTestId('hook-connect-shell-zsh').click() + const command = await page.getByTestId('record-terminal-command').textContent() + expect(command).toMatch(/redlog-ok-/) + await expect(page.getByTestId('record-terminal-verified')).toHaveCount(0) + const shell = findShellTarget(); expect(shell).toBeTruthy() + const recorded = await runZsh(shell!, { rc: readFileSync(join(tmpHome, '.zshrc'), 'utf8'), commands: [command!], timeoutSeconds: 20, + env: { _REDLOG_DIR: join(tmpHome, '.redlog'), REDLOG_EXTERNAL_SESSION: '', REDLOG_TERMINAL: '' } }) + expect(recorded.ok, recorded.error).toBe(true) + await expect(page.getByTestId('record-terminal-verified')).toBeVisible() + await page.getByTestId('record-terminal-details-toggle').click() + const outputCommand = await page.getByTestId('record-terminal-output-command').textContent() + const output = await runZsh(shell!, { rc: readFileSync(join(tmpHome, '.zshrc'), 'utf8'), commands: [outputCommand!], timeoutSeconds: 20, + env: { _REDLOG_DIR: join(tmpHome, '.redlog'), REDLOG_EXTERNAL_SESSION: '', REDLOG_TERMINAL: '' } }) + expect(output.ok, output.error).toBe(true) + await expect(page.getByTestId('record-terminal-output-verified')).toBeVisible() + await page.evaluate(async () => { await window.redlog.plugins.setEnabled('pack-host-monitors', false) }) + await openSettingsPage(page, 'captureControl') + await page.getByTestId('pack-repair-hostMonitors').click() + const toggle = page.getByTestId('pack-switch-hostMonitors') + await expect(toggle).not.toBeChecked() + const config = await page.evaluate(() => window.redlog.config.get()) + expect(config.packs?.hostMonitors).toBe(false) + await resizeMainWindow(app, 800, 600) + for (const setting of ['hooks', 'browser', 'captureControl']) { + await openSettingsPage(page, setting) + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1)).toBe(true) + } +} diff --git a/scripts/settings-search-sources.mjs b/scripts/settings-search-sources.mjs index 52eb0bc6..75079c22 100644 --- a/scripts/settings-search-sources.mjs +++ b/scripts/settings-search-sources.mjs @@ -7,11 +7,16 @@ export const PAGE_SOURCES = { { file: 'settings/HooksPanel.tsx' }, { file: 'settings/ManualStepList.tsx' }, { file: 'WslPanel.tsx' }, + { file: 'RecordTerminalFlow.tsx' }, + { file: 'CapturePreparation.tsx' }, + { file: 'Settings.tsx', after: "{tab === 'hooks'", before: "{tab === 'captureControl'" }, + { file: 'settings/CapturePackGroup.tsx' }, ], // Spec 049: the AI agents page folded into Capture sources — one writer for // per-tool project permissions, so one place its strings are indexed from. captureControl: [ { file: 'settings/CaptureControlPage.tsx' }, + { file: 'settings/CapturePackGroup.tsx' }, { file: 'settings/AgentsPanel.tsx' }, { file: 'settings/ExternalCaptureGroup.tsx' }, // The last-delivered line every source row carries (T028). It is a @@ -21,7 +26,7 @@ export const PAGE_SOURCES = { ], loot: [{ file: 'settings/LootRulesGroup.tsx' }], retention: [{ file: 'settings/RetentionPage.tsx' }], - browser: [{ file: 'settings/BrowserPage.tsx' }, { file: 'settings/BrowserPanel.tsx' }], + browser: [{ file: 'settings/BrowserPage.tsx' }, { file: 'settings/BrowserPanel.tsx' }, { file: 'HttpCaptureStep.tsx' }, { file: 'CapturePreparation.tsx' }], scope: [{ file: 'settings/ScopePage.tsx' }, { file: 'settings/ProjectSettings.tsx' }], network: [{ file: 'settings/NetworkPage.tsx' }], integrity: [{ file: 'settings/IntegrityPanel.tsx' }], diff --git a/specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md b/specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md index ea997d16..5a466572 100644 --- a/specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md +++ b/specs/062-recording-workflow-consolidation/ai-durability-and-session-boundaries.md @@ -1,6 +1,6 @@ # AI capture durability, privacy and package lifecycle — 2026-10-24 -Status: implementation in progress. Follows the user-approved capture-trust direction in PR #439 (read-only reference, not merged here). +Status: bounded implementation and acceptance complete. Follows the user-approved capture-trust direction in PR #439 (read-only reference, not merged here). Exact candidate, subsequent integration and platform limits are recorded in verification.md; Spec 062 remains Draft with T008–T013 open. ## Observable requirements (Specify / Clarify) @@ -74,7 +74,7 @@ Dependency order: failure regressions → host commits/pause → session gates/U - [x] Ignored metadata and ordinary pending parents preserve real provenance. - [x] Per-session stop survives reload, another session continues, reallow starts now. - [x] Managed update, tamper refusal, rollback and receipt preservation have tests. -- [ ] Final full/package/native evidence recorded below at closeout. +- [x] Final full/package/native evidence recorded in verification.md at closeout. These are bounded contracts. A cwd whitelist cannot detect private prompts or private tool-read content inside an authorized session; no semantic classifier diff --git a/specs/062-recording-workflow-consolidation/contracts/export-dialog.md b/specs/062-recording-workflow-consolidation/contracts/export-dialog.md index 87ec5877..f22dd26f 100644 --- a/specs/062-recording-workflow-consolidation/contracts/export-dialog.md +++ b/specs/062-recording-workflow-consolidation/contracts/export-dialog.md @@ -6,4 +6,9 @@ Change format → same subset; change range → same format. Current selection p Confirm is unavailable while resolving, on preview error, for an empty selection, or after a failed execution until a new plan resolves. Controls are locked during execution. Escape/cancel closes the dialog when not running, invalidates pending previews and restores focus. Preview failure allows retry and closing. No source evidence is changed. -Unsupported policies are explained, not advertised as applied. HAR preview describes exchanges using the canonical counts. Empty web scope warns but permits export. Success offers reveal; only successful execution closes the dialog. +Unsupported policies are explained, not advertised as applied. HAR preview describes exchanges using the canonical counts. Empty web scope warns but permits export. Success retains the completed artifact in the dialog until the operator closes it. + + +### Completed result and reopen (2026-10-24) + +Success transitions to a completed-result view in the same dialog, rather than closing it. Close dismisses the view, not the artifact. Reopening can inspect the active project's last successful result on this machine; Export another starts a new frozen current selection. Failed/canceled attempts do not replace the last successful result. Missing artifact, unreadable metadata and failed reveal are explicit and do not claim a new completion. Machine metadata write failure warns while retaining the actual successfully created result. Result lookup grants no arbitrary path access. diff --git a/specs/062-recording-workflow-consolidation/data-model.md b/specs/062-recording-workflow-consolidation/data-model.md index ab14e2e1..b9a7e436 100644 --- a/specs/062-recording-workflow-consolidation/data-model.md +++ b/specs/062-recording-workflow-consolidation/data-model.md @@ -1,7 +1,13 @@ # Data model — US6 -No persisted entity changes. Existing ExportRequest and ResolvedExportPlan remain canonical. +No evidence/schema changes. Existing ExportRequest and ResolvedExportPlan remain canonical. The bounded 2026-10-24 increment adds machine-local delivery metadata, described below. Dialog draft: frozen current subset and optional view disabledReason; selected range current/all; selected supported format; mask and PII choices; excluded attachment ids; selected destination. Range and format are independent. Default range is current. No loaded row ids are used to reconstruct selection. Preview state: resolving / resolved / failed. A request edit or close invalidates the prior generation synchronously. Only the latest successful generation supplies an executable plan. Running locks draft controls; execution failure retains draft and requires a fresh plan. + +## Last successful delivery (2026-10-24) + +`ExportDelivery` contains the actual artifact path, supported format, completion timestamp and derived file availability. Main stores one successful receipt per `(project id, resolved project root)` in Electron userData/recent-exports. It writes an owner-only temporary file and atomically renames it after all export/manifest writes succeed. Cancel/failure never replaces the receipt. Missing receipt is distinct from unreadable/invalid metadata; missing/inaccessible artifacts retain their identity. Metadata-write failure preserves the artifact and reports a warning. + +The receipt is UI state, not evidence, an export plan, a saved query, a backup or a portable project artifact. It is not included in bundles. Reopening/copying a project on another machine does not import path authorization. The renderer cannot write arbitrary receipts. Reveal may use existing approved locations or the exact successful artifact for the current project. Selection, scope, attachments and fingerprint still come solely from ExportPlan. diff --git a/specs/062-recording-workflow-consolidation/manual-acceptance.md b/specs/062-recording-workflow-consolidation/manual-acceptance.md index ce3a7599..2002f4ba 100644 --- a/specs/062-recording-workflow-consolidation/manual-acceptance.md +++ b/specs/062-recording-workflow-consolidation/manual-acceptance.md @@ -72,3 +72,14 @@ bytes 相同,包內驗證器通過。這些採既有合成資料與 source bui 測試、截圖所指出的部分來源已驗證版面、原生寬版與自動化窄版,以及 Windows/ Linux runner 結果。NOT RUN 列表示完整案例仍未跑完,不抹除已有子步驟的證據, 也不將來源版或 CI 自動化冒充下載信任、桌面手動操作或原生 WSL 驗收。 + +## 2026-10-24 合併後 main + +[合併後 Mac 驗收](post-merge-mac-acceptance-20261024.md) 記錄 375f2ee3 的實際 DMG/ZIP 資源、安裝副本與 AI journey,以及原生新建/設定保存/專案隔離/更新檢查。保存重試發現的殘留錯誤另列 T072。僅補 Mac 子步驟,不更改上方完整跨平台列的 NOT RUN 或 T008–T013。 + + +2026-10-25 bounded follow-up: reused the already-open isolated Mac package for +source settings, explicit Zsh install/waiting state, actual bundle completion, +Finder selection, Last export reopening and native smaller-window inspection. +See verification.md T073–T078 for exact evidence and test-consolidation results. +This does not complete the broader T008–T013/platform matrix. diff --git a/specs/062-recording-workflow-consolidation/plan.md b/specs/062-recording-workflow-consolidation/plan.md index 3e81ecd1..4551c38e 100644 --- a/specs/062-recording-workflow-consolidation/plan.md +++ b/specs/062-recording-workflow-consolidation/plan.md @@ -260,3 +260,17 @@ Clarify: user already selected optional local integrations, Claude then Codex, d On 2026-10-24 the user chose app-supplied offline packages, not registration-only installation. Provider code and source-location metadata live under optional-plugins//; existing Vite tooling builds each independently into out/optional-ai. Packaging places that directory outside the auto-discovered plugins root. The manager discovers manifests, validates API/identity/path/checksum, copies only the chosen manifest/parser under the user plugin directory and returns a module path only for unchanged managed files. Host initialization loads only installed, active, explicitly authorized sources; stop/uninstall/project switch tears down watchers and drops module instances. Shared filesystem watching is supplied by the host so optional packages need no native dependency resolution outside the app. Generic third-party executable plugins remain refused. No marketplace, network download or arbitrary code grant is introduced. Acceptance additions: no parser in the main bundle; no installed parser before user installation; no module execution on listing/install; require-cache entry appears only after project activation and disappears after stop; changed code cannot be loaded; a fourth fixture package is discoverable without editing core. Existing source evidence survives uninstall. + + +## 2026-10-24 bounded convergence — #434 / #435 / #436 + +T073–T077 implement the already specified US1/US2/US6 gaps. Requirements-quality checklist is 14/14 checked. No extension hooks are registered. Clarify has no user question: installation remains machine-level, recording project-level; external producers are not controlled by a fake start switch; only actual capture proves verification. This is desktop Electron, with narrow windows/zoom rather than a mobile application. + +- Reuse RecordTerminalFlow for explicit host/WSL connect and reverify actions. Merely opening settings does not mount an installing flow. Keep shell-specific adapters, preflight and nonce matching, output truthfulness and removal/undo. UI consumes actual backend capabilities; no custom executable installer or new platform support is invented. Windows output settings sit with terminal capture. Runtime dependency preparation stays inside the initiating source entry using CapturePreparation. +- Sources reuse existing health/read-state and plugin APIs. Failed/unknown plugin availability cannot become true or enable a recording control. Managed integration repair is an explicit machine action on the source row; it never changes project capture permission. External producers retain setup/diagnostics and a direct plugin-management route, without a start/stop switch RedLog cannot honor. +- A main-owned RecentExportStore retains one successful artifact's format/path/completion time per project root/id in the machine-private Electron userData directory. No event content/query or new exporter/database schema. It is written only after the approved exporter succeeds, atomically with owner-only permissions. Missing metadata means no result; corrupt/unreadable metadata is an error, not an empty success. Missing/inaccessible artifacts are explicit. A failed metadata write does not relabel an already produced artifact as failed: execution returns the actual result with a warning. +- Recent-result lookup takes no renderer path. Reveal keeps existing destination approval and may additionally reveal only the exact successful artifact recorded for the active project, never an arbitrary sibling/parent from persisted metadata. This allows approved custom-destination results after restart without persisting a general folder grant. +- Export success remains in the same dialog with filename/destination/reveal/close; opening the dialog again can inspect the last result. A new export is explicit and freezes the then-current view selection. Recent results are keyed by project and stale renderer replies cannot cross a project change. Cancellation/failure never update the last-success record. +- Bundle and source-applicable HAR are primary format choices; JSON/NDJSON/timeline remain available through Other formats. Fresh use defaults to bundle (HTTP retains HAR); an explicitly remembered general format remains respected. This supersedes only the previous fresh JSON default, not selection, attachment or policy invariants. Detailed preview/attachments can fold only if missing/truncated/whole-cast/scope warnings remain visible. + +Analyze: canonical ExportPlan selection/execution and source lifecycle remain authoritative. Machine-only result metadata is not evidence, a backup or an additional audit chain. No marketplace, workflow engine, new shell, C2 operation, migration shim or expanded AI capture. Risks to test first: read-only settings must not install, stale attempts, unavailable packs, rejected repair, export/reveal/write failures, cross-project lookup, result persistence, format/selection independence and keyboard/size behavior. Existing T008–T013 stay open for their wider platform/OS matrix. diff --git a/specs/062-recording-workflow-consolidation/post-merge-mac-acceptance-20261024.md b/specs/062-recording-workflow-consolidation/post-merge-mac-acceptance-20261024.md new file mode 100644 index 00000000..d9994928 --- /dev/null +++ b/specs/062-recording-workflow-consolidation/post-merge-mac-acceptance-20261024.md @@ -0,0 +1,90 @@ +# 合併後 Mac 安裝包與設定驗收 — 2026-10-24 + +## 基準與隔離 + +- main:`375f2ee392878203d330928aadba5ddc0bb84e5c`,含 #437 / #439 / #441 / #442。 +- 遠端 [main CI](https://github.com/guan4tou2/REDLOG/actions/runs/40096631179) 已核對成功:macOS/Windows/Ubuntu unit,以及 Ubuntu Electron e2e。不是三平台原生手動驗收。 +- 在獨立 managed worktree 建立 `codex/post-merge-mac-acceptance`,沒有切換共用目錄的分支,也沒有改動 #432 / #391。 +- 從原工作區複製本輪 T068–T071 的未提交收尾文件;原檔與私人 traffic DB 保留,不讀取後者。 +- Mac arm64,Electron 44.4.1,版本 0.18.3;本機 ad-hoc 簽章、未公證。沒有發版、覆蓋 /Applications 或改系統 CA。 + +## 合併基準的實際安裝包 + +從原始碼 build → electron-builder DMG / ZIP(publish never)→ package resources 通過。 +`verify:package-resources` 檢查 hooks、relay、verifier、內建 packs、獨立可選 AI 解析器、checksum,以及 strict/deep 簽章。 + +| 檔案 | SHA-256 | +| --- | --- | +| RedLog-0.18.3-arm64.dmg | 90d4551a20c635c9e805a403d0370e86812da9c22cd2587c5a4083c94960c4e2 | +| RedLog-0.18.3-arm64-mac.zip | 7f7ae8efe88013dccddc155513ca54f6f7c3d65d360de330f5b68aaa35ca41ac | +| 安裝後 Resources/app.asar | 7332cdae0ba999cde50f8d2615f41000bb888cde46f5839d1a4b979e5e70ba73 | + +檔案位於 `/private/tmp/redlog-main-375f2ee3-dist/`。DMG 經 hdiutil verify、唯讀掛載,再以 ditto 複製至 +`/private/tmp/redlog-main-375f2ee3-acceptance/installed/RedLog.app` 並卸載。 +這是檔案系統安裝步驟,不冒稱 Finder 拖曳或下載後 Gatekeeper 手動驗收。 +安裝後資源/簽章再次通過;ZIP 完整性檢查通過。 + +從 DMG 複製出的 App:**6/6 AI 流程通過(47.8s)**,包括真實 Claude Code +serializer 的新/既有來源目錄、Claude/Codex 配對 I/O/搜尋/實際交付、 +單一對話與全域暫停,以及明確安裝。Claude 只連 loopback fake API,使用假金鑰, +沒有私人對話或付費推論。記錄在 `/private/tmp/redlog-main-375f2ee3-installed-e2e.log`。 + +## 原生手動操作 + +以獨立 HOME 與 --user-data-dir 啟動,未開 REDLOG_E2E;所有操作透過原生介面完成。 +專案為 `PostMerge-A-20261024` / `PostMerge-B-20261024`,均為合成驗收資料。 + +| 子步驟 | 結果與範圍 | +| --- | --- | +| 空白首次啟動、新建 A | PASS;看到空列表,UI 建立專案;HUD 初始顯示,為操作主視窗才將測試 HUD 隱藏 | +| 日常設定入口 | PASS;直接開設定分類,不強制重跑首次引導 | +| 修改 A 名稱後立即離開 | PASS;離開確認後,列表與磁碟均保留 PostMerge-A-saved | +| 新建 B、修改操作員後快速切到關於 | PASS;B 持久化 Operator-B-only,A 保持 Operator,專案 id 不變 | +| Mac 手動檢查更新 | PASS(檢查/狀態);顯示目前 0.18.3 與手動 DMG 更新方式,檢查時間更新;主程序確認 remote release 0.18.3 | +| 設定真實寫入拒絕 | PASS(保存契約);只將 B/config.yaml 暫設 0444,修改 Operator-B-retried;顯示保存失敗/重試,原檔 SHA-256 不變 | +| 恢復權限、點重試 | PASS(資料);恢復原 0644,UI 顯示已保存,磁碟可讀回新值 | +| 重試後錯誤提示 | FAIL(基準);同時殘留舊「設定儲存失敗」toast,稍後仍可見;已記錄 T072 並做最小修正 | + +沒有較新版本可測,也未執行另一版本的實際更新/重啟。關閉/暫停/退出、 +完整互動終端、HUD 全域焦點、Windows/Linux 桌面與乾淨依賴安裝仍各自待驗。 +不因此關閉 T008–T013 或將 Spec 062 改為 Verified。 + +## T072:重試成功後清除已解決的錯誤 + +原因:error toast 原本不逾時,Settings 失敗時建立它,成功時卻只更新 inline 保存狀態。 +修正以 project id 為通知 key,只在保存成功後移除該專案的保存錯誤;拒絕/失敗仍保留, +其他專案及匯出錯誤不受影響。不新增文案、元件、設定或頁面。 + +RED:新的使用者流程測試在修改前為 1 failed / 8 passed;成功後仍有三則 alert(應只剩其他兩則)。 +GREEN:Settings + Toast 三份相關測試 **20/20 通過**,包括拒絕重試不能清除錯誤, +成功只清除自己的錯誤,以及既有錯誤不逾時規則。 +記錄:`/private/tmp/redlog-main-save-toast-red.log` / `redlog-main-save-toast-green.log`。 + +另在現有設定 Electron journey 加入 POSIX 真實檔案唯讀/恢復重試/重新載入的回歸。 +原生工具在後續退出時明確回報 Mac 已鎖定;已請操作者解鎖。原設定權限已還原, +隔離程序已停止。修正後原生確認及最終測試結果續記於下方,不將未送達的動作算成完成。 + + +## 修正後收尾 + +- 五道 gate 依序通過:typecheck → specs → architecture → i18n → unit; + **4,234 passed / 28 existing skips;421 files passed / 5 skipped(116.00s)**。 + `/private/tmp/redlog-postmerge-save-toast-full.log`。 +- 修正後 signed Mac app 重新建置至 `/private/tmp/redlog-postmerge-save-toast-dist/mac-arm64/RedLog.app`; + package resources / independent optional AI checksums / strict-deep signature 通過。 + 這是額外修正候選,不是前面純 main DMG 的相同 bytes,也沒有發版。 +- 修正候選實際打包 E2E:真實 POSIX 唯讀檔案/失敗提示/恢復權限/重試/重載讀回, + **1/1 passed(8.3s)**。檔案權限於 finally 還原。 + `/private/tmp/redlog-postmerge-save-toast-installed.log`。 +- 操作者解鎖後,正常確認舊候選的退出提示;先前 SIGTERM 只觸發退出提示, + 不把它當成程序已結束。接著啟動修正候選至原隔離 HOME/profile,列表仍有 + PostMerge-A-saved 與 B,B 的操作員為 Operator-B-retried。 +- **原生手動 PASS**:再將 B/config.yaml 設成 0444,輸入 Operator-B-final; + 顯示 inline 失敗與 error toast,SHA-256 不變。恢復原 0644,實際點重試; + inline 顯示已保存,舊錯誤的文字與關閉鈕從 AX tree 移除。磁碟讀回新值。 +- 透過正常退出提示「停止並結束」退出修正候選,主程序 exit 0;測試檔案權限 + 還原,Node better-sqlite3 binding 還原並通過 in-memory query。 +- 本輪沒有 commit / push / merge / release。共用原工作區未切分支;新內容留在 + codex/post-merge-mac-acceptance。T072 完成,不關閉 T008–T013。 + +修正候選 app.asar SHA-256:`4e47505f644bc694bb7f22edbb4d888b66a1e7353c118e17fadc64ec5b8409d8`。 diff --git a/specs/062-recording-workflow-consolidation/tasks.md b/specs/062-recording-workflow-consolidation/tasks.md index df6a4c80..1983dc09 100644 --- a/specs/062-recording-workflow-consolidation/tasks.md +++ b/specs/062-recording-workflow-consolidation/tasks.md @@ -167,9 +167,31 @@ T042–T045 complete the seven recorded layout recommendations, not installer or T065–T067 complete the bounded optional-package/source-placement increment. Exact per-page state/size coverage, package/native evidence and remaining recommendations are in source-placement-and-ui-review-20261024.md; T008–T013 remain open. -- [ ] T068 [US5] Reproduce and repair JSONL/per-message progress on DB/raw/checkpoint failures, preserving event UUID deduplication and complete raw units across retries/restart. -- [ ] T069 [US5] Make global pause omit new AI sidecar content and establish durable resume boundaries, including delayed notifications and partial lines; retain metadata-only capture gaps. -- [ ] T070 [US5] Add project/provider/session exclusion and explicit from-now reauthorization through existing AI source rows; show observed sessions without a new page or semantic classifier. -- [ ] T071 [US5] Repair official managed-package upgrade/removal without executing stale/modified code or adopting foreign files; test and record exact acceptance/platform limits. +- [x] T068 [US5] Reproduce and repair JSONL/per-message progress on DB/raw/checkpoint failures, preserving event UUID deduplication and complete raw units across retries/restart. +- [x] T069 [US5] Make global pause omit new AI sidecar content and establish durable resume boundaries, including delayed notifications and partial lines; retain metadata-only capture gaps. +- [x] T070 [US5] Add project/provider/session exclusion and explicit from-now reauthorization through existing AI source rows; show observed sessions without a new page or semantic classifier. +- [x] T071 [US5] Repair official managed-package upgrade/removal without executing stale/modified code or adopting foreign files; test and record exact acceptance/platform limits. T068–T071 follow ai-durability-and-session-boundaries.md; they do not complete T008–T013. + +- [x] T072 [US2] Reproduce the native stale settings-save error after successful retry; clear only the resolved project notification, retaining failed/refused retries and unrelated errors. Verify the post-merge Mac package and record native/platform limits. + + +## Phase 5: Convergence — #434 / #435 / #436 + +- [x] T073 [US1] Share RecordTerminalFlow between initial setup, host settings and WSL install/repair; opening settings must remain read-only. Respect detected adapter capabilities, distinguish installed/command/output proof, preserve shell-specific commands/profiles and stale-attempt/project isolation (FR-001/FR-004, partial). +- [x] T074 [US2] Keep dependency preparation and source repair in the current source entry, move Windows output configuration beside terminal setup, refuse unknown/failed pack availability, and provide direct managed-integration recovery without silently authorizing capture or adding fake external-producer switches (FR-003/FR-005/FR-011, partial). +- [x] T075 [US6] Retain the successful artifact in the export dialog and expose the last successful delivery for the current project on this machine; persist only main-owned metadata after actual success. Bound reveal to approved locations or that exact completed artifact; missing, inaccessible, corrupt metadata and failed reveal remain distinguishable (FR-014/US6.7, partial). +- [x] T076 [US6] Present evidence bundle/HAR as primary applicable formats and existing other formats in an advanced choice; retain all selection/policy/attachment/snapshot invariants and important warnings. Verify cancellation, failure/retry and reopening cannot mislabel an old result as the new export (FR-012/FR-013/FR-016, partial). +- [x] T077 Verify these bounded journeys test-first, run the ordered gates and affected installed/native Mac journeys at wide/narrow sizes, update domain/feature and issue evidence, and preserve unrun Windows/Linux/OS acceptance as NOT RUN (Constitution VIII, FR-016/SC-001). + +- [x] T078 Consolidate the affected test journeys: reuse a process for ordinary settings with project/preference/viewport resets, include source connection in the export journey, retain process isolation for IPC fault injection, remove redundant renderer checks, and document one targeted verification run instead of repeatedly launching source/package copies. + +T073–T078 close only the bounded #434/#435/#436 increment and its test cleanup. +The 2026-10-25 verification entry records exact source/package/native coverage, +including the consolidated-test selector failure and its targeted repair. +T008–T013 and the wider platform matrix remain open; Spec 062 remains Draft. + +- [x] T079 Close the bounded follow-up: distinguish a manual adapter choice from the detected recommendation; refresh hooks, terminal policy and integration availability on return without running installers or changing project permissions; use HUD-off non-HUD test fixtures with explicit HUD-on acceptance and keep the product default unchanged. + +- [x] T080 Reproduce the Windows late-discovery pause leak with a filesystem clock ahead of Date.now; persist a filesystem-calibrated recording boundary and reuse it for resumed sessions. Verify paused content stays absent after restart while genuinely new conversations still record; retain the CI/platform results in verification.md. diff --git a/specs/062-recording-workflow-consolidation/verification.md b/specs/062-recording-workflow-consolidation/verification.md index ced5d0db..22bfe07b 100644 --- a/specs/062-recording-workflow-consolidation/verification.md +++ b/specs/062-recording-workflow-consolidation/verification.md @@ -1336,3 +1336,296 @@ plus managed-upgrade/rollback and source-control behavior are included. Final packaged/native acceptance follows below. T008–T013 and Spec 062's Draft status are not closed by this bounded follow-up; no commit/push/merge/release. + + +### Bounded packaged and native acceptance / T068–T071 closeout + +The local Mac arm64 candidate at +`/private/tmp/redlog-ai-trust-20261024/packaged/mac-arm64/RedLog.app` +passed all **6/6 installed AI journeys (43.4s)**: actual installed Claude Code +serializer with new/existing transcript directories, Claude/Codex full paired +I/O/search/real JSON delivery and lifecycle isolation, individual-session/global +pause privacy, and explicit installation. Resource checks, package checksums and +strict/deep ad-hoc signature verification passed. These are local package checks, +not notarization, distribution or release acceptance. +Log: `/private/tmp/redlog-ai-trust-installed-final.log`. + +The same candidate passed **5/5 source/settings journeys (13.1s)**, including +800px layout, both locales at zoom 1/1.5/2, applicable fields, external-source +failure versus stopped states and AI source recovery without implicit permission. +Log: `/private/tmp/redlog-ai-trust-layout-final.log`. + +Native manual QA used a fresh isolated HOME and the project +`AI-Trust-Native-20261024`. Installed only Claude, selected the temporary client +work directory through the actual folder picker and explicitly enabled project +recording. The pre-authorization tool-read boundary warning was visible. Codex +and OpenCode remained uninstalled. A real Claude Code process used a loopback +fake response with dummy credentials; prompt, actual local printf tool input/ +output and reply arrived. No private transcript, real account or remote inference +was used. + +Manually opened Conversations with native keyboard Return after an accessibility +click focused, but did not open, the disclosure. Stopped that Claude session: +the row changed to Excluded / Record new content while the provider checkbox +stayed enabled. Clicked Record new content: the row returned to Capturing after +host refresh and the other session remained Capturing. A transient Paused state +was visible while the host refreshed; final state was read back. Used the macOS +Window → Move & Resize → Top Left menu to reduce the window from raster +2800×1800 to 1750×1096 (Retina screenshots). The full UUIDs, states and actions +remained visible without overlap or horizontal clipping. The native check covers +these controls at two actual window sizes; broader all-route coverage belongs to +source-placement-and-ui-review-20261024.md. Package-update rollback/tamper paths +were fault-tested, not manually simulated as an actual previous-release upgrade. + +During this acceptance the shared checkout independently advanced to **3e84483c** +(PR #442), including 7f3ea39c and the follow-up 9932a31a. This session did not +commit, push or merge those changes. The earlier 4,207-unit and installed-package +results precede the new retry/retention changes and must not be reported as a +full-suite/package result for that later commit. After fetching origin, current +3e84483c passed typecheck → specs → architecture → i18n and **63/63 focused +cases** across tailer durability (22), official packages (23) and source controls +(18). This includes retained failed children, duplicate queue/tool counts, +automatic idle retry and missing-versus-truncated retained sidecars. +Log: `/private/tmp/redlog-ai-trust-latest-focused.log`. + + +Built the latest 3e84483c sources and reran the same six AI journeys against +source-launched Electron: **6/6 passed (42.4s)**, including both real-Claude +loopback serializer cases. Log: `/private/tmp/redlog-ai-trust-latest-e2e.log`. +This is additional current-source coverage, not a claim that the earlier native +package contains the later retry patch. No production code changed during this +closeout. The isolated native app was quit and the workspace better-sqlite3 +binding restored to Node; an in-memory SQLite open/query succeeded. + +T068–T071 are complete for their bounded requirements. Spec 062 remains Draft: +T008–T013 still require the recorded wider install/settings/terminal/HUD/updater +acceptance. Windows/Linux actual client installation, Claude IDE UI, real Codex +producer versions, images/hidden reasoning and all vendor versions are not +certified here. This session did not publish a release or send code to an +external model. The unrelated traffic database was not read or staged. + + +## Post-merge main acceptance and T072 — 2026-10-24 + +Remote main 375f2ee3 includes #437/#439; its three-platform unit and Ubuntu e2e +CI were verified successful. Follow-up work is isolated on +codex/post-merge-mac-acceptance, based on that main, without switching the +shared checkout. Earlier references to PRs still open are dated observations, +not the current merge state. + +[Post-merge Mac acceptance](post-merge-mac-acceptance-20261024.md) records the +actual main DMG/ZIP hashes, installed resource/signature checks and 6/6 installed +AI journeys. Native first launch, project save/switch isolation, a real config +write refusal/retry and manual update check were exercised. This exposed a stale +error toast after successful save: T072 clears only that project's resolved +notification while retaining errors for unsuccessful retries and other operations. + +RED reproduced the stale toast; Settings/Toast 20-case regression passed after +the fix. Final ordered five gates: **4234 passed / 28 existing skips**, 421 files +passed / 5 skipped (116.00s). Corrected signed package resources passed; real-file +installed retry journey **1/1 passed (8.3s)**. After the operator unlocked the +Mac, the same failure/restore/retry was completed natively: Saved appeared and +the old error disappeared. Candidate quit normally; original modes and Node +SQLite binding restored. T072 complete. Spec 062 stays Draft with T008–T013 open; +Windows/Linux native installers, Gatekeeper, interactive/OS HUD and actual +update/defer/restart are not closed by this Mac increment. No release or push. + + +## 2026-10-25 — Source recovery, completed exports and test consolidation (T073–T078) + +Base: main `375f2ee3`, isolated branch `codex/post-merge-mac-acceptance`. +These changes are local and uncommitted, not a new main/remote CI result. + +### Implemented boundaries + +- Host settings and WSL reuse `RecordTerminalFlow`. Opening settings does not + install; explicit Connect / Verify / repair does. Installed is separate from + command/output proof. WSL's installed-state refresh does not remount/reinstall + the active nonce attempt. The backend's actual adapter availability governs + selection; no PowerShell-on-macOS backend support was invented. +- Existing dependency preparation is available in the selected source page. + Plugin-list failure/unknown cannot offer a working project capture switch. + A known disabled bundled integration can be restored without changing project + capture permissions. Windows output configuration is beside terminal setup; + external producers retain actual start commands and heartbeat states. +- Export completion remains visible; reopen can inspect the project's last + successful artifact. Main-owned, atomic owner-only machine metadata stores + path/format/time, never query/evidence. Cancel/failure cannot replace it. + Missing/unreadable/corrupt metadata and missing/inaccessible artifacts remain + distinct. A receipt-write fault warns without destroying a successful export. + Exact custom-artifact reveal after restart does not grant sibling access. +- Fresh default is bundle, applicable HTTP starts HAR, and other existing + formats remain available. Selection/policy/attachment/snapshot behavior is + unchanged. Renderer state is keyed to the active project. + +### Automated evidence + +The source/shell-settings regressions were reproduced first (missing shared +flow and unhandled detect failure). Export receipt/recovery tests failed before +implementation. Fault tests cover atomic replacement refusal and malformed +valid JSON as well as truncation/corruption. Unique privacy, evidence, source +identity and failure-recovery regressions were retained. + +Before the final test-only cleanup, the ordered five gates all passed, with +**4,252 passed / 28 skipped**, **423 passing files / 5 skipped files**. +Log: `/private/tmp/redlog-workflow-verified-five-gates.log`. + +Source Electron: **16/16 passed** across settings, source connection, export +recovery and filtered export, including English/Traditional Chinese and actual +400/533/800 CSS viewport geometry at zoom. Log: +`/private/tmp/redlog-workflow-e2e.log`. + +Mac ARM64 installed-copy source/export journeys: **8/8 passed**. Package +resource/seal check passed. Installed Claude/Codex regressions plus the source +external-session regression: **4/4 passed** (the latter still launches source, +so this is not four installed-producer proofs). Logs: +`/private/tmp/redlog-workflow-installed-e2e.log`, +`/private/tmp/redlog-workflow-delivery-regression.log`, +`/private/tmp/redlog-workflow-package-resources.log`. + +### Test maintenance requested by the operator + +Removed five duplicate renderer cases from export-menu. Their owners remain: +PII/format and failed-run/draft/retry behavior in export-unified-dialog; modal +semantics in its opening scenario; controls/state in the actual interaction +cases. The old superficial "all controls exist" and controlled-checkbox shape +checks no longer pretend to prove focus/remount correctness; real attachment +focus and keyboard E2E remain. + +Ordinary settings cases now share one app, each creating a fresh project and +resetting preferences/viewport. Replaced IPC handlers and machine-integration +mutations retain two isolated apps. Source connection now runs in the export +journey's app instead of a standalone source-recovery spec. The 14-case selected +journey therefore needs four launches when all pass, down from eight. + +Consolidated run: the six settings cases passed. The export flow then exposed a +legacy partial-text locator: `getByLabel('Export')` also matched the now-healthy +capture status label containing "export". This was a test defect hidden by the +previous no-source state. Repaired with an exact button target, without altering +product code or weakening assertions. Only export was rerun: **8/8 passed**; +the six already-passed settings cases were not rerun. Logs: +`/private/tmp/redlog-consolidated-e2e.log` (first failure), +`/private/tmp/redlog-consolidated-export-green.log` (repair). + +Focused renderer verification after removing duplicates: **32/32 passed**; +lightweight type/spec/architecture/i18n gates passed. No repeated full suite or +source/package replay was run for this test-only cleanup. Procedure and +coverage owners are recorded in docs/TESTING.md and e2e/README.md. + +### Native check, reusing the already-open Mac candidate + +The same isolated installed app remained open; no additional launch was needed. +At the original window size, source settings displayed three usable cards. +The explicit Zsh Connect action installed only in the disposable HOME and showed +installed / waiting for its nonce, not verified. No command/output proof is +claimed from this native click; actual producer proof is in the installed E2E. + +Exported a real bundle through the native dialog, inspected its retained path +and completion time, clicked Show in folder and observed Finder select that +exact bundle. Closed the completion view, reopened Last export, and read the +same path/time. Native Window → Move & Resize → Top Left reduced the window; +long path wrapped, all completion actions remained visible and source settings +stacked in one column. Other sources showed AI integrations uninstalled and +optional capture off. Quit the isolated app after acceptance; process exit was +verified. Native screenshots/AX were inspected in the tool output; no private +network/IP screenshots were published. + +### Explicit remaining limits + +T008–T013 remain uncompleted broader acceptance. Windows PowerShell, real WSL, +Linux desktop/installer, clean dependency download/privilege rejection and full +OS update/restart matrices are **NOT RUN** here. This local Mac candidate is +ad-hoc signed, not notarized. No new release, remote PR or CI run is claimed. +Spec 062 stays Draft. The temporary package is under +`/private/tmp/redlog-workflow-unification-acceptance/installed/RedLog.app`. + + +## 2026-10-25 — Three bounded closeout corrections (T079) + +The manual initial hook and the actual preflight recommendation are separate: +choosing Bash from Settings installs/selects Bash while a detected Zsh retains +the detected badge. Changing the recommendation does not restart detection or +reinstall the selected adapter. + +Hooks, terminal mode/class policy, bundled pack availability and WSL discovery +now use the existing throttled focus/visibility revalidation. These callbacks +read only; they never restore an integration, install an adapter or change the +project's capture selection. `useReadState` drops stale replies. The command +verification attempt stays mounted during source status refresh. + +`launchWithTempHome` suppresses auto-show in non-HUD E2E processes via +`REDLOG_E2E_HIDE_HUD`; main honors that only when `REDLOG_E2E=1` also holds. +No product default or project setting changed. HUD-specific direct launches +retain their display; the shared-helper HUD case explicitly requests +`{ showHud: true }`. The source journey asserts both the unchanged +`overlay.autoShow: true` config and no visible HUD window. + +RED: **3 failed / 23 passed** for the incorrect manual recommendation and the +missing policy/pack refresh. GREEN: **91/91 passed** across seven affected +renderer/focus/default suites. An existing assertion was changed to await the +actual preflight result instead of treating the requested shell as detected. +Logs: `/private/tmp/redlog-closeout-red.log`, +`/private/tmp/redlog-closeout-green.log`. + +Typecheck, spec, architecture and i18n gates passed; build passed. Only two +source Electron cases were run, in two processes: external-source connection +(with HUD hidden) and the explicit HUD release/input case (HUD shown), +**2/2 passed**. Log: `/private/tmp/redlog-closeout-e2e.log`. +The HUD input case exercises Electron native delivery, not an OS-global +keyboard claim. There was no full-suite rerun, package rebuild or manual GUI +replay. Workspace SQLite is restored to the Node ABI after the source E2E. + +The prior installed Mac candidate predates T079; no refreshed installation +acceptance is claimed. T008–T013 remain open, Spec 062 remains Draft, and all +changes remain local/uncommitted. + + +## 2026-10-25 — Push candidate and internal-test artifact + +The final candidate (T072–T079) passed the five required pre-push gates in +order: typecheck, specs, architecture, i18n and unit. Unit: **4,249 passed / +28 skipped; 423 files passed / 5 skipped**. Log: +`/private/tmp/redlog-push-unit-20261025.log`. The earlier no-full-suite statements +record their respective increments; this is the final push gate, not another +manual or GUI replay. + +A fresh Mac arm64 DMG containing T079 was built with publish disabled: +`/private/tmp/redlog-internal-20261025/RedLog-0.18.3-arm64.dmg`. +SHA-256: `4ed7c3d0b204ecf22479bc01d295d938cb347cf1a7578e43ab11e25bd5f59d25`. +DMG integrity, packaged capture resources, optional AI checksums and app seal +verification passed. Build/package logs are +`/private/tmp/redlog-internal-build-20261025.log`, +`/private/tmp/redlog-internal-package-20261025.log` and +`/private/tmp/redlog-internal-resources-20261025.log`. +The Node SQLite binding was restored after packaging. + +This refreshed artifact has build/resource/integrity verification; the prior +native/installed journeys and the final source T079 E2E are recorded above. +The refreshed DMG was not installed and manually replayed again. This supports +bounded Mac internal testing, not cross-platform or release acceptance. +Ad-hoc signing is not notarization. T008–T013 remain open and Spec 062 remains +Draft. No main merge or release is authorized by this push. + + +## PR #443 — Windows pause boundary correction (T080) + +The first PR run on 857a31cd passed macOS/Ubuntu unit and both installer smoke +workflows, but Windows failed the existing late-discovery pause assertion. +E2E was skipped, not passed. No merge was performed. + +The comparison mixed Date.now with fractional filesystem birth times. A clock +lead caused a conversation created while paused to look newer than resume. +Extending the existing case with a 1-second filesystem/system clock lead +reproduced the identical raw sidecar leak locally (1 failed / 22 passed). + +Recording boundaries now take the later of the wall clock and the rounded-up +timestamp of their staged metadata file, persist that value atomically, and +reuse it for resumed sessions. Source contents are never copied to that marker. +The same case verifies restart preserves omission and a genuinely new +post-resume conversation still records. The neighboring durability/pause tests +are retained; checks are not skipped or weakened. + +Local focused checks: **31/31 passed**. Ordered pre-push gates passed; unit +**4,250 passed / 28 skipped**. Logs: `/private/tmp/redlog-pr443-clock-green.log` +and `/private/tmp/redlog-pr443-final-unit.log`. Windows CI results are tracked +by PR #443. This does not complete the wider T008–T013 acceptance. diff --git a/src/core/export-delivery.ts b/src/core/export-delivery.ts new file mode 100644 index 00000000..614dfe21 --- /dev/null +++ b/src/core/export-delivery.ts @@ -0,0 +1,7 @@ +/** Delivery metadata, not evidence or a reusable export selection. */ +export interface ExportDelivery { + artifactPath: string + format: 'json' | 'ndjson' | 'bundle' | 'har' | 'timeline' + completedAt: number + availability: 'available' | 'missing' | 'unavailable' +} diff --git a/src/main/index.ts b/src/main/index.ts index 953ee362..8e65f79b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -89,6 +89,7 @@ import { replaySpoolDirectory } from '../core/spool-replay' import { registerContextMenuIpc } from './context-menu' import { registerClipboardIpc } from './ipc/clipboard' import { registerDataExportIpc } from './ipc/data-export' +import { RecentExportStore } from './services/recent-export' import { registerOverlayIpc, setOverlayPassThrough, bindOverlayReleaseShortcut, stopOverlayMouseTracking, configureOverlayState, handleIpExposedChange, startOverlayMouseTracking, @@ -1093,7 +1094,8 @@ function startProject(project: ProjectMeta): void { }, 5 * 60 * 1000) if (!overlayWindow) { - overlayWindow = createOverlayWindow(loadOverlayPosition(), { show: config.overlay?.autoShow !== false }) + const hideHudForTest = process.env.REDLOG_E2E === '1' && process.env.REDLOG_E2E_HIDE_HUD === '1' + overlayWindow = createOverlayWindow(loadOverlayPosition(), { show: config.overlay?.autoShow !== false && !hideHudForTest }) bindOverlayReleaseShortcut(overlayWindow.webContents) // §8: remember where the operator put it, per display. `moved` fires // throughout a drag, so debounce to the end of it. @@ -1319,7 +1321,7 @@ app.whenReady().then(() => { onPluginsChanged: () => { if (activeProject) applyCapturePacks(loadConfig(getProjectPath(activeProject))) } } registerOverlayIpc(ipcMain, ipcCtx) - registerDataExportIpc(ipcMain, ipcCtx) + registerDataExportIpc(ipcMain, ipcCtx, { recentExports: new RecentExportStore(path.join(app.getPath('userData'), 'recent-exports')) }) registerTerminalIpc(ipcMain, ipcCtx) registerPluginsIpc(ipcMain, ipcCtx) registerOperatorsIpc(ipcMain, ipcCtx) diff --git a/src/main/ipc/data-export.ts b/src/main/ipc/data-export.ts index 346df4b6..9e3e9f2a 100644 --- a/src/main/ipc/data-export.ts +++ b/src/main/ipc/data-export.ts @@ -1,4 +1,6 @@ import { dialog, shell, type IpcMain } from 'electron' +import { RecentExportStore } from '../services/recent-export' +import type { ExportDelivery } from '../../core/export-delivery' import { isInsideDir } from '../../core/paths' import path from 'path' import fs from 'fs' @@ -169,9 +171,16 @@ function exportPlanPreview(plan: ExportPlan): Pick { + const project = ctx.getActiveProject() + if (!project) return { ok: false as const, error: 'no-active-project' } + try { return { ok: true as const, delivery: recentExports.get(project.id, getProjectPath(project)) } } + catch (error) { return { ok: false as const, error: (error as Error).message } } + }) // Folders the operator picked in the system dialog this session. Only these // can be named as an export destination. const approvedDestinations = new Set() @@ -206,9 +215,13 @@ export function registerDataExportIpc( if (!project || typeof target !== 'string') return false const resolved = path.resolve(target) const inApproved = [...approvedDestinations].some((dir) => isInsideDir(dir, resolved)) - if ((!isInsideDir(getProjectPath(project), resolved) && !inApproved) || !fs.existsSync(resolved)) return false - shell.showItemInFolder(resolved) - return true + try { + const knownResult = !isInsideDir(getProjectPath(project), resolved) && !inApproved + ? recentExports.get(project.id, getProjectPath(project))?.artifactPath === resolved : true + if (!knownResult || !fs.existsSync(resolved)) return false + shell.showItemInFolder(resolved) + return true + } catch { return false } }) ipcMain.handle('data:executeExportPlan', (_e, input?: { planId?: string; destDir?: string }) => { @@ -320,7 +333,12 @@ export function registerDataExportIpc( completedAt: new Date().toISOString() }, null, 2), { flag: 'wx' }) } - return { ok: true as const, planId: plan.id, fingerprint: plan.fingerprint, artifactPath, counts: plan.counts, warnings: [] } + if (!artifactPath) return { ok: false as const, error: 'export-artifact-missing' } + const delivery: ExportDelivery = { artifactPath, format: plan.request.format, completedAt: Date.now(), availability: 'available' } + const warnings: string[] = [] + try { recentExports.remember(project.id, getProjectPath(project), delivery) } + catch { warnings.push('recent-export-not-saved') } + return { ok: true as const, planId: plan.id, fingerprint: plan.fingerprint, artifactPath, counts: plan.counts, warnings, delivery } } catch (error) { return { ok: false as const, planId: plan.id, fingerprint: plan.fingerprint, error: (error as Error)?.message ?? String(error) } } diff --git a/src/main/services/recent-export.ts b/src/main/services/recent-export.ts new file mode 100644 index 00000000..744b8faa --- /dev/null +++ b/src/main/services/recent-export.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createHash, randomUUID } from 'node:crypto' +import type { ExportDelivery } from '../../core/export-delivery' + +type SavedDelivery = Omit +interface Receipt extends SavedDelivery { projectId: string; projectDir: string } + +/** One main-owned receipt per project on this machine. Never stored in the + * project, where importing a folder could grant access to someone else's path. */ +export class RecentExportStore { + private memory = new Map() + constructor(private readonly dir?: string) {} + + private key(projectId: string, projectDir: string): string { + return createHash('sha256').update(JSON.stringify([projectId, path.resolve(projectDir)])).digest('hex') + } + + get(projectId: string, projectDir: string): ExportDelivery | null { + const key = this.key(projectId, projectDir) + let value: unknown = this.memory.get(key) + if (this.dir) { + try { value = JSON.parse(fs.readFileSync(path.join(this.dir, `${key}.json`), 'utf8')) } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw new Error('recent-export-unreadable') + } + } + if (value === undefined && !this.dir) return null + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('recent-export-invalid') + const receipt = value as Receipt + if (receipt.projectId !== projectId || receipt.projectDir !== path.resolve(projectDir) + || !Number.isSafeInteger(receipt.completedAt) || receipt.completedAt <= 0 + || typeof receipt.artifactPath !== 'string' || !path.isAbsolute(receipt.artifactPath) + || !['json', 'ndjson', 'bundle', 'har', 'timeline'].includes(receipt.format)) { + throw new Error('recent-export-invalid') + } + let availability: ExportDelivery['availability'] = 'available' + try { + const stat = fs.statSync(receipt.artifactPath) + if (!stat.isFile() && !stat.isDirectory()) availability = 'unavailable' + } catch (error) { + availability = (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unavailable' + } + return { artifactPath: receipt.artifactPath, format: receipt.format, completedAt: receipt.completedAt, availability } + } + + remember(projectId: string, projectDir: string, delivery: SavedDelivery): void { + const key = this.key(projectId, projectDir) + const receipt: Receipt = { artifactPath: delivery.artifactPath, format: delivery.format, completedAt: delivery.completedAt, projectId, projectDir: path.resolve(projectDir) } + if (this.dir) { + fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 }) + const temp = path.join(this.dir, `${key}.${randomUUID()}.tmp`) + try { + fs.writeFileSync(temp, JSON.stringify(receipt), { flag: 'wx', mode: 0o600 }) + fs.renameSync(temp, path.join(this.dir, `${key}.json`)) + } catch (error) { + try { fs.unlinkSync(temp) } catch { /* no uncommitted file left */ } + throw error + } + } + this.memory.set(key, receipt) + } +} diff --git a/src/main/services/tailer-host.ts b/src/main/services/tailer-host.ts index 79453851..46a82f98 100644 --- a/src/main/services/tailer-host.ts +++ b/src/main/services/tailer-host.ts @@ -322,9 +322,18 @@ function sessionKey(agentKind: string, sessionId: string): string { let resumeBoundary = 0 function boundaryPath(): string { return path.join(getProjectDir(), 'agent-transcripts', '.recording-boundary.json') } function saveBoundary(paused: boolean): void { - const file = boundaryPath(), at = Date.now(), tmp = file + '.tmp' + const file = boundaryPath(), tmp = file + '.tmp' + let at = Date.now() fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }) - try { fs.writeFileSync(tmp, JSON.stringify({ paused, at }), { mode: 0o600 }); fs.renameSync(tmp, file) } + try { + fs.writeFileSync(tmp, JSON.stringify({ paused, at }), { mode: 0o600 }) + // Windows file timestamps can lead the coarser Date.now clock. Compare + // source timestamps against a filesystem boundary too, or delayed discovery + // can mistake paused content for a conversation created after resume. + at = Math.max(at, Math.ceil(fs.statSync(tmp).mtimeMs)) + fs.writeFileSync(tmp, JSON.stringify({ paused, at }), { mode: 0o600 }) + fs.renameSync(tmp, file) + } catch (e) { try { fs.unlinkSync(tmp) } catch { /* staging metadata only */ }; throw e } resumeBoundary = at } @@ -1010,7 +1019,7 @@ const onRecording = (recording: boolean): void => { s.checkpointPaused = true if (s.adapter.perMessageDir) catchUpPerMessageDir(s) else discardJsonl(s) - s.resumedAt = Date.now(); s.skipMissingParents = true + s.resumedAt = resumeBoundary; s.skipMissingParents = true if (recording) { s.checkpointPaused = false; checkpoint(s) } } catch (e) { noteCaptureError(`agent-${s.agentKind}`, e) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8d1287dc..5460b9e2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -250,6 +250,7 @@ const api: RedLogAPI = { data: { resolveExportPlan: (request: ExportRequest) => ipcRenderer.invoke('data:resolveExportPlan', request), executeExportPlan: (input: { planId: string; destDir?: string }) => ipcRenderer.invoke('data:executeExportPlan', input), + recentExport: () => ipcRenderer.invoke('data:recentExport'), exportDestination: () => ipcRenderer.invoke('data:exportDestination') as Promise, pickExportDir: () => ipcRenderer.invoke('data:pickExportDir') as Promise, revealExport: (target: string) => ipcRenderer.invoke('data:revealExport', target) as Promise diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a57df530..a95c8e59 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -302,7 +302,7 @@ export default function App(): JSX.Element { {/* §10: one export control, in the shell rather than six places. Its scope is an option, not a location. */} - navigate(settingsTarget('scope'))} /> + navigate(settingsTarget('scope'))} /> @@ -400,7 +400,7 @@ export default function App(): JSX.Element { {view === 'loot' && } {view === 'bookmarks' && openInTimeline('', ts)} />} {view === 'http_history' && }> navigate(settingsTarget('browser'))} />} - {view === 'settings' && }>} + {view === 'settings' && }>} diff --git a/src/renderer/src/components/CapturePreparation.tsx b/src/renderer/src/components/CapturePreparation.tsx index 2c43d8b6..130a1394 100644 --- a/src/renderer/src/components/CapturePreparation.tsx +++ b/src/renderer/src/components/CapturePreparation.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { useReadState } from '../lib/readState' import { INSTALL_TIMEOUT_MS } from '../../../core/dependency-install' import { useI18n } from '../i18n' import { Button } from './Button' @@ -140,3 +141,23 @@ export function CapturePreparation({ preflight, failed, onRecheck, onSkip, onLea ) } + +/** Source-local dependency repair; mounting this view never starts an installer. */ +export function RuntimePreparation({ onClose }: { onClose: () => void }): JSX.Element { + const { t } = useI18n() + const read = useReadState(() => window.redlog.runtime.preflight()) + const missing = preparationChecks(read.state.value) + return
+ {read.state.phase === 'loading' &&

{t('captureSetup.checking')}

} + {read.state.phase === 'failed' &&
+

{t('firstRun.preflightFailedTitle')}

+

{read.state.error}

+ +
} + {read.state.phase === 'loaded' && <> + {missing.length === 0 ?

{t('settings.dependenciesReady')}

+ :
    {missing.map(check => )}
} + } + +
+} diff --git a/src/renderer/src/components/ExportMenu.tsx b/src/renderer/src/components/ExportMenu.tsx index 4e70d131..bb75fe34 100644 --- a/src/renderer/src/components/ExportMenu.tsx +++ b/src/renderer/src/components/ExportMenu.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react' +import type { ExportDelivery } from '../../../core/export-delivery' import { Button, TITLEBAR_CONTROL, TITLEBAR_ICON } from './Button' import { useNarrowViewport } from '../hooks/useNarrowViewport' import { Download, X } from 'lucide-react' @@ -179,10 +180,10 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) const { filter } = useSharedFilter() const currentSubset: ExportSubset = viewExport?.request.subset ?? { kind: 'selection', projection: 'events', filter: toEventFilter(filter) } const [draft, setDraft] = useState(() => { - let format: ExportFormat = 'json' + let format: ExportFormat = 'bundle' try { const saved = localStorage.getItem(FORMAT_KEY) - if (saved === 'ndjson' || saved === 'bundle') format = saved + if (saved === 'json' || saved === 'ndjson' || saved === 'bundle') format = saved } catch { /* remembering a format is optional */ } return { format, range: 'current' as 'current' | 'all', scrubPii: false, maskScope: true, excludeAttachments: [] as string[] } }) @@ -192,6 +193,14 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) const [destDir, setDestDir] = useState(null) const [defaultDir, setDefaultDir] = useState(null) const [open, setOpen] = useState(false) + const [completion, setCompletion] = useState(null) + const [recent, setRecent] = useState(null) + const [recentError, setRecentError] = useState(false) + const [recentLoading, setRecentLoading] = useState(false) + const [revealError, setRevealError] = useState(false) + const [resultWarning, setResultWarning] = useState(false) + const recentSequence = useRef(0) + const live = useRef(true) const [busy, setBusy] = useState(false) const [pending, setPending] = useState(null) const [resolvedPlan, setResolvedPlan] = useState(null) @@ -254,12 +263,36 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) void loadPreview(p) } + const loadRecent = async (): Promise => { + const mine = ++recentSequence.current + setRecentLoading(true) + setRecentError(false) + try { + const result = await window.redlog.data.recentExport() + if (mine !== recentSequence.current || !live.current) return + if (!result.ok) throw new Error(result.error) + setRecent(result.delivery) + } catch { if (mine === recentSequence.current && live.current) setRecentError(true) } + finally { if (mine === recentSequence.current && live.current) setRecentLoading(false) } + } + + const reveal = async (): Promise => { + if (!completion) return + setRevealError(false) + try { if (!await window.redlog.data.revealExport(completion.artifactPath)) setRevealError(true) } + catch { setRevealError(true) } + } + const openDialog = (): void => { selection.current = JSON.parse(JSON.stringify({ subset: currentSubset, request: viewExport?.request, disabledReason: viewExport?.disabledReason })) const format = viewExport?.request.format === 'har' || viewExport?.request.format === 'timeline' - ? viewExport.request.format : draft.format === 'har' || draft.format === 'timeline' ? 'json' : draft.format + ? viewExport.request.format : draft.format === 'har' || draft.format === 'timeline' ? 'bundle' : draft.format setDestDir(null) + setCompletion(null) + setRevealError(false) + setResultWarning(false) setOpen(true) + void loadRecent() updateDraft({ ...draft, format, range: 'current', excludeAttachments: [], scrubPii: false }) } @@ -294,6 +327,8 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) const closeAll = (): void => { attachmentFocus.current = null + ++recentSequence.current + setCompletion(null) ++previewSequence.current setPending(null) setResolvedPlan(null) @@ -310,7 +345,7 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [open, busy]) - useEffect(() => () => { ++previewSequence.current }, []) + useEffect(() => { live.current = true; return () => { live.current = false; ++previewSequence.current; ++recentSequence.current } }, []) const confirmExport = async (): Promise => { if (!pending) return @@ -322,14 +357,18 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) if (!result.ok) throw new Error(result.error) const path = result.artifactPath if (!path) throw new Error(t('toast.exportFailedWhy')) - toast(t('export.done', { label: pending.label }), { - type: 'success', - why: path, - action: { label: t('export.reveal'), onClick: () => { void window.redlog.data.revealExport?.(path) } }, - duration: 8000 - }) - closeAll() + if (!live.current) return + ++previewSequence.current + setPending(null) + setResolvedPlan(null) + ++recentSequence.current + setRecentLoading(false) + setCompletion(result.delivery) + setRecent(result.delivery) + setRevealError(false) + setResultWarning(result.warnings.includes('recent-export-not-saved')) } catch (e) { + if (!live.current) return // Stay open with the preview. A plan is single-use, so resolve it again // for the retry: the operator retries the same request, not a stale id. setRunError(String((e as Error)?.message ?? e)) @@ -339,7 +378,7 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) detail: String((e as Error)?.message ?? e) }) } finally { - setBusy(false) + if (live.current) setBusy(false) } } @@ -355,9 +394,10 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) await loadPreview(pending) } - const formats: ExportFormat[] = ['json', 'ndjson', 'bundle'] + const formats: ExportFormat[] = ['bundle'] const viewFormat = selection.current?.request?.format - if (viewFormat === 'har' || viewFormat === 'timeline') formats.push(viewFormat) + if (viewFormat === 'har') formats.push(viewFormat) + const otherFormats: ExportFormat[] = ['json', 'ndjson', ...(viewFormat === 'timeline' ? ['timeline' as const] : [])] const capability = capabilitiesFor(draft.format) return ( @@ -390,10 +430,21 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps) className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[91] w-[min(560px,92vw)] max-h-[82vh] flex flex-col overflow-hidden whitespace-normal break-words bg-redlog-surface border border-redlog-border rounded-lg shadow-2xl" >
-

{t('export.title')}

+

{t(completion ? 'export.completedTitle' : 'export.title')}

+ {!completion && recent && }
+ {completion ? ( +
+
{completion.format.toUpperCase()}
+ {completion.artifactPath} + {completion.availability !== 'available' &&

{t(completion.availability === 'missing' ? 'export.artifactMissing' : 'export.artifactUnavailable')}

} + {resultWarning &&

{t('export.recentNotSaved')}

} + {revealError &&

{t('artifacts.revealFailed')}

} +
+ ) : <> + {recentError &&
{t('export.recentFailed')}
}
+ {!formats.includes(draft.format) && }

{draft.range === 'current' ? t('export.range.current', { summary: subsetSummary(selection.current!.subset, t) }) : t('export.range.allHint')}

{t(`export.formatHelp.${draft.format}`)}

{totalCount === 0 &&

{t('export.empty')}

} @@ -556,10 +614,18 @@ export function ExportMenu({ totalCount, onOpenScopeSettings }: ExportMenuProps)
) : null} + }
- - {resolvedPlan && !previewLoading && } + + {completion && } + {(completion || (resolvedPlan && !previewLoading)) && }
diff --git a/src/renderer/src/components/RecordTerminalFlow.tsx b/src/renderer/src/components/RecordTerminalFlow.tsx index 0bf255f2..e0a41cdb 100644 --- a/src/renderer/src/components/RecordTerminalFlow.tsx +++ b/src/renderer/src/components/RecordTerminalFlow.tsx @@ -61,7 +61,7 @@ const ACTIVATION_TIMEOUT_MS = 60_000 /** Which adapter the flow actually installs. * - * The detected one wins when it exists here; when it does not, it stays + * The requested initial adapter wins when it exists here; when it does not, it stays * selected so the screen can say why rather than silently swapping in a shell * the operator did not ask for. Only with no recommendation at all does the * first available adapter become the default. */ @@ -71,8 +71,9 @@ function pickInitialAdapter(choices: AdapterChoice[], recommended: string | null return choices.find((c) => c.available)?.hookId ?? choices[0].hookId } -export function RecordTerminalFlow({ target, onPrepare, onSelectedLabel }: { +export function RecordTerminalFlow({ target, onPrepare, onSelectedLabel, onInstalled }: { target: RecordTarget + onInstalled?: () => void onPrepare: () => void /** Which shell the flow is actually driving. The entry button above names the * detected shell as the recommendation, but the picker inside can select a @@ -86,8 +87,9 @@ export function RecordTerminalFlow({ target, onPrepare, onSelectedLabel }: { const [installError, setInstallError] = useState('') const [nonce, setNonce] = useState('') const [choices, setChoices] = useState([]) - const recommended = target.kind === 'host' ? target.hookId : null - const [selectedHook, setSelectedHook] = useState(recommended) + const initialHook = target.kind === 'host' ? target.hookId : null + const recommended = target.kind === 'host' ? preflight?.shell?.hookId ?? null : null + const [selectedHook, setSelectedHook] = useState(initialHook) // WSL has one path and no picker, so it is "detected" from the first frame. const [detected, setDetected] = useState(target.kind !== 'host') const [detectVersion, setDetectVersion] = useState(0) @@ -111,7 +113,7 @@ export function RecordTerminalFlow({ target, onPrepare, onSelectedLabel }: { if (!live) return const found = hostAdapterChoices(hooks ?? [], window.redlog.platform ?? '') setChoices(found) - setSelectedHook(pickInitialAdapter(found, recommended)) + setSelectedHook(pickInitialAdapter(found, initialHook)) setDetected(true) }) .catch((error) => { @@ -120,7 +122,7 @@ export function RecordTerminalFlow({ target, onPrepare, onSelectedLabel }: { setPhase('detect-failed') }) return () => { live = false } - }, [recommended, target.kind, detectVersion]) + }, [initialHook, target.kind, detectVersion]) const selectedChoice = choices.find((c) => c.hookId === selectedHook) ?? null const expectedShell: ShellName | null = target.kind === 'wsl' @@ -175,6 +177,7 @@ export function RecordTerminalFlow({ target, onPrepare, onSelectedLabel }: { return } if (!current()) return + onInstalled?.() setPhase('installed') setNonce(activationNonce()) setPhase('waiting-for-activation') @@ -474,9 +477,8 @@ function VerifiedScope({ shell, label }: { shell: ShellName; label: string }): J * means output was recorded, and the same line with no output means only * the metadata was. Nothing is claimed about output that was never seen. * - * The bare command is what proves zsh's automatic relay; bash has no - * printf-style relay at its prompt, so there it runs behind `redlog-run` and - * the check proves the wrapper — the only output path bash has. */ + * The bare command is what proves zsh's automatic relay; bash uses the same ordinary-command output check; neither is credited + * with output on the strength of a command event alone. */ function OutputCheck({ shell, onResult }: { shell: ShellName; onResult?: (kind: CanaryResult['kind'] | null) => void }): JSX.Element { const { t } = useI18n() const [canary] = useState(() => outputCanary(activationNonce())) @@ -490,7 +492,7 @@ function OutputCheck({ shell, onResult }: { shell: ShellName; onResult?: (kind: }) }, [canary, result?.kind]) - const viaRun = shell === 'bash' ? `redlog-run ${canary.command}` : canary.command + const viaRun = canary.command return (

{t('firstRun.record.outputCheck')}

diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 950318a6..2f0dda9b 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import AboutPage from './settings/AboutPage' import { useI18n } from '../i18n' import { registerPendingSave } from '../lib/pendingSaves' -import { toast } from './Toast' +import { dismissToast, toast } from './Toast' import type { ConfigState, HookInfo } from './settings/SettingsShared' import { isWindows } from './settings/SettingsShared' import WslPanel from './WslPanel' @@ -14,6 +14,7 @@ import NetworkPage from './settings/NetworkPage' import BrowserPage from './settings/BrowserPage' import ScopePage from './settings/ScopePage' import CaptureControlPage from './settings/CaptureControlPage' +import CapturePackGroup from './settings/CapturePackGroup' import HooksPanel from './settings/HooksPanel' import PluginsPanel from './settings/PluginsPanel' import IntegrityPanel from './settings/IntegrityPanel' @@ -70,7 +71,6 @@ export default function Settings({ request = null }: { request?: { page: Setting // happened. const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle') const [hooks, setHooks] = useState([]) - const [hookLoading, setHookLoading] = useState(null) const { t } = useI18n() useEffect(() => { @@ -112,10 +112,12 @@ export default function Settings({ request = null }: { request?: { page: Setting // back lets main refuse a write aimed at a project the operator has since // switched away from, rather than applying a stale form to a new // engagement. + const failureKey = `settings-save:${next.engagement?.id}` const write = window.redlog.config.save(next, { expectProjectId: next.engagement?.id }) .then((ok) => { window.dispatchEvent(new CustomEvent('redlog:config-saved')) if (ok === false) keep() + else dismissToast(failureKey) if (live.current) { setSaveState(ok === false ? 'failed' : 'saved') if (ok !== false) setTimeout(() => { if (live.current) setSaveState('idle') }, 1500) @@ -125,7 +127,7 @@ export default function Settings({ request = null }: { request?: { page: Setting .catch(() => { keep() if (live.current) setSaveState('failed') - toast(t('toast.saveFailed'), 'error') + toast(t('toast.saveFailed'), { type: 'error', key: failureKey }) return false }) .finally(() => { if (inFlight.current === write) inFlight.current = null }) @@ -364,12 +366,16 @@ export default function Settings({ request = null }: { request?: { page: Setting
{tab === 'general' && } {tab === 'hud' && } - {tab === 'browser' && setTab('hooks')} config={config} setConfig={setConfig} t={t} />} + {tab === 'browser' && } {tab === 'network' && } {tab === 'scope' && <>} {tab === 'hooks' && ( <> - + + {(isWindows || config.packs?.windowsOutput === true) && <> + setTab('plugins')} /> + } {isWindows && } )} diff --git a/src/renderer/src/components/Toast.tsx b/src/renderer/src/components/Toast.tsx index e6caa786..7ade14b0 100644 --- a/src/renderer/src/components/Toast.tsx +++ b/src/renderer/src/components/Toast.tsx @@ -74,11 +74,15 @@ export function capToasts(list: T[]): T[] { type PushFn = (message: string, opts: ToastOptions) => void let _push: PushFn = () => {} +let _dismiss: (key: string) => void = () => {} export function toast(message: string, opts: ToastType | ToastOptions = {}): void { _push(message, typeof opts === 'string' ? { type: opts } : opts) } +/** Clear a resolved operation without dismissing unrelated errors. */ +export function dismissToast(key: string): void { _dismiss(key) } + /** The action already happened and can be reversed. */ export function toastUndo(message: string, onUndo: () => void, opts: ToastOptions = {}): void { _push(message, { ...opts, duration: opts.duration ?? UNDO_MS, action: { label: '__undo__', onClick: onUndo } }) @@ -151,7 +155,8 @@ export function ToastContainer(): JSX.Element | null { useEffect(() => { _push = push - return (): void => { _push = () => {} } + _dismiss = key => setToasts(prev => prev.filter(x => x.key !== key)) + return (): void => { _push = () => {}; _dismiss = () => {} } }, [push]) // Prune by deadline on a steady interval. Each toast carries its own, so a diff --git a/src/renderer/src/components/WslPanel.tsx b/src/renderer/src/components/WslPanel.tsx index f7d3c165..3a456cbe 100644 --- a/src/renderer/src/components/WslPanel.tsx +++ b/src/renderer/src/components/WslPanel.tsx @@ -1,4 +1,8 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' +import { useRevalidateOnFocus } from '../hooks/useRevalidateOnFocus' +import { useReadState } from '../lib/readState' +import { RecordTerminalFlow, type RecordTarget } from './RecordTerminalFlow' +import { RuntimePreparation } from './CapturePreparation' import { SectionLabel } from './SectionLabel' import { Button } from './Button' import { toast } from './Toast' @@ -9,57 +13,21 @@ interface WslPanelProps { } export default function WslPanel({ t }: WslPanelProps): JSX.Element { - const [distros, setDistros] = useState([]) - const [networkMode, setNetworkMode] = useState<'mirrored' | 'nat' | 'not-configured'>('not-configured') - const [loading, setLoading] = useState(true) + const [connecting, setConnecting] = useState(null) + const [preparing, setPreparing] = useState(false) + const read = useReadState(async () => { + const [distros, networkMode] = await Promise.all([window.redlog.wsl.listDistros(), window.redlog.wsl.getNetworkMode()]) + return { distros, networkMode } + }) + const distros = read.state.value?.distros ?? [] + const networkMode = read.state.value?.networkMode + const refresh = read.retry + useRevalidateOnFocus(refresh) const [actionBusy, setActionBusy] = useState(null) const [diagnostics, setDiagnostics] = useState(null) - const refresh = async (): Promise => { - setLoading(true) - try { - const [ds, nm] = await Promise.all([ - window.redlog.wsl.listDistros(), - window.redlog.wsl.getNetworkMode() - ]) - setDistros(ds) - setNetworkMode(nm) - } catch { - setDistros([]) - } - setLoading(false) - } - - useEffect(() => { refresh() }, []) - - const handleInstall = async (distro: string, shell: 'bash' | 'zsh'): Promise => { - const key = `${distro}-${shell}-install` - setActionBusy(key) - try { - const result = await window.redlog.wsl.installHook(distro, shell) - if (result.success) { - toast(t('settings.wsl.hookInstalled', { distro, shell }), 'success') - await refresh() - } else { - toast(t('settings.wsl.hookInstallFailed', { distro }), { - type: 'error', - why: t('settings.wsl.hookInstallFailedWhy'), - detail: result.message, - action: { label: t('common.retry'), onClick: () => { void handleInstall(distro, shell) } } - }) - } - } catch (e) { - toast(t('settings.wsl.hookInstallFailed', { distro }), { - type: 'error', - why: t('settings.wsl.distroUnreachableWhy'), - detail: String((e as Error)?.message ?? e), - action: { label: t('common.retry'), onClick: () => { void handleInstall(distro, shell) } } - }) - } - setActionBusy(null) - } - const handleUninstall = async (distro: string, shell: 'bash' | 'zsh'): Promise => { + setConnecting(null) const key = `${distro}-${shell}-uninstall` setActionBusy(key) try { @@ -103,7 +71,13 @@ export default function WslPanel({ t }: WslPanelProps): JSX.Element { setActionBusy(null) } - if (loading) { + if (read.state.phase === 'failed' && !read.state.value) return
+

{t('settings.wsl.readFailed')}

+

{read.state.error}

+ +
+ + if (read.state.phase === 'loading' && !read.state.value) { return (
{t('settings.wsl.title')} @@ -148,6 +122,11 @@ export default function WslPanel({ t }: WslPanelProps): JSX.Element {
+ {read.state.phase === 'loading' &&

{t('common.loading')}

} + {read.state.phase === 'failed' &&
+

{t('settings.wsl.readFailed')}

{read.state.error}

+ +
} {/* Network mode banner */} {networkMode === 'mirrored' && (
@@ -218,7 +197,6 @@ export default function WslPanel({ t }: WslPanelProps): JSX.Element { {(['bash', 'zsh'] as const).map((shell) => { const status = distro.hookStatus[shell] if (status === 'no-shell') return null - const installKey = `${distro.name}-${shell}-install` const uninstallKey = `${distro.name}-${shell}-uninstall` return (
@@ -226,15 +204,13 @@ export default function WslPanel({ t }: WslPanelProps): JSX.Element { {hookStatusLabel(status)} - {status === 'not-installed' && ( - - )} {status === 'installed' && ( +
: !available ? (
-

{t('settings.packRemoved')}

+

{t(plugin?.status === 'disabled' ? 'settings.packRemoved' : 'settings.packUnavailable')}

+ {plugin?.status === 'disabled' && } {onOpenPlugins && ( }
{t('settings.externalSetup')} {describe(hook) &&

{describe(hook)}

} diff --git a/src/renderer/src/components/settings/HooksPanel.tsx b/src/renderer/src/components/settings/HooksPanel.tsx index 9f1bb9b0..84a32d89 100644 --- a/src/renderer/src/components/settings/HooksPanel.tsx +++ b/src/renderer/src/components/settings/HooksPanel.tsx @@ -1,8 +1,12 @@ import { useState, useEffect } from 'react' -import { toast } from '../Toast' import { FieldGroup, type HookInfo } from './SettingsShared' import { isForeignShellHook } from '../../lib/hookPlatform' import { removeHookWithUndo } from '../../lib/hookRemoval' +import { RecordTerminalFlow } from '../RecordTerminalFlow' +import { RuntimePreparation } from '../CapturePreparation' +import { shellNameForHook } from '../../lib/terminalActivation' +import { useRevalidateOnFocus } from '../../hooks/useRevalidateOnFocus' +import { useReadState } from '../../lib/readState' import { SetupAndTeardown } from './ManualStepList' // Built-in hooks describe themselves in English in hooks-manager (main @@ -50,76 +54,35 @@ function TerminalPolicyNote({ policy, t }: { ) } -export default function HooksPanel({ hooks, setHooks, hookLoading, setHookLoading, t }: { +export default function HooksPanel({ hooks, setHooks, t }: { hooks: HookInfo[] setHooks: (h: HookInfo[] | ((prev: HookInfo[]) => HookInfo[])) => void - hookLoading: string | null - setHookLoading: (id: string | null) => void t: (key: string, vars?: Record) => string }): JSX.Element { + const [connecting, setConnecting] = useState(null) + const [preparing, setPreparing] = useState(false) + const read = useReadState(async () => { + const found = await window.redlog.hooks.detect() + if (!Array.isArray(found)) throw new Error('invalid-hook-list') + return found + }) const [expanded, setExpanded] = useState(null) - const [policy, setPolicy] = useState(null) + const policy = useReadState(async (): Promise => { + const api = window.redlog.hooks + return await api.terminalPolicy?.() ?? null + }) + useRevalidateOnFocus(() => { read.retry(); policy.retry() }) useEffect(() => { - (window.redlog as { hooks: { detect: () => Promise } }).hooks.detect().then(setHooks) - }, []) + if (read.state.phase === 'loaded') setHooks(read.state.value) + }, [read.state]) - useEffect(() => { - // Not cached: `redlog mode manual` happens in a terminal RedLog knows - // nothing about, and a panel showing a stale `auto` is worse than one - // showing nothing. An older preload has no such method, so a renderer - // running against one leaves the note out rather than throwing. - const api = (window.redlog as { hooks?: { terminalPolicy?: () => Promise } }).hooks - api?.terminalPolicy?.().then(setPolicy).catch(() => setPolicy(null)) - }, []) - - // mitmproxy missing: install it here rather than typing the command into the - // terminal. Same backend as the capture-preparation page; a `sudo` or missing - // prerequisite comes back as a message and the manual step stays below. - const installMitm = async (hook: HookInfo): Promise => { - setHookLoading(hook.id) - try { - const result = await window.redlog.runtime.install('mitmdump') - if (result.success) toast(t('settings.hookInstalled', { name: hook.name }), 'success') - else if (!result.cancelled) { - toast(t('settings.hookInstallFailed', { name: hook.id }), { - type: 'error', why: t('settings.hookFailedWhy'), detail: result.message, - action: { label: t('common.retry'), onClick: () => { void installMitm(hook) } } - }) - } - } catch (e) { - toast(t('settings.hookInstallFailed', { name: hook.id }), { type: 'error', detail: e instanceof Error ? e.message : String(e) }) - } - setHooks(await (window.redlog as { hooks: { detect: () => Promise } }).hooks.detect()) - setHookLoading(null) - } - const handleToggle = async (hook: HookInfo): Promise => { - const hooksApi = (window.redlog as { hooks: { install: (id: string) => Promise<{ success: boolean; message: string }> } }).hooks - if (hook.installed) { - const show = (installed: boolean) => (): void => - setHooks((prev) => prev.map((h) => (h.id === hook.id ? { ...h, installed } : h))) - removeHookWithUndo(hook.id, t, { - hide: show(false), - restore: show(true), - refresh: () => { void (window.redlog as { hooks: { detect: () => Promise } }).hooks.detect().then(setHooks) } - }) - return - } - setHookLoading(hook.id) - const result = await hooksApi.install(hook.id) - if (result.success) toast(result.message, 'success') - else { - toast(t('settings.hookInstallFailed', { name: hook.id }), { - type: 'error', - why: t('settings.hookFailedWhy'), - detail: result.message, - action: { label: t('common.retry'), onClick: () => { void handleToggle(hook) } } - }) - } - const updated = await (window.redlog as { hooks: { detect: () => Promise } }).hooks.detect() - setHooks(updated) - setHookLoading(null) + const remove = (hook: HookInfo): void => { + if (connecting === hook.id) setConnecting(null) + const show = (installed: boolean) => (): void => + setHooks(prev => prev.map(h => h.id === hook.id ? { ...h, installed } : h)) + removeHookWithUndo(hook.id, t, { hide: show(false), restore: show(true), refresh: read.retry }) } const platform = (window as { redlog?: { platform?: string } }).redlog?.platform @@ -152,7 +115,7 @@ export default function HooksPanel({ hooks, setHooks, hookLoading, setHookLoadin {hook.name} {hook.installed && ( - {t('settings.hookActive')} + {t('settings.shellInstalled')} )} {isManual && ( @@ -169,32 +132,22 @@ export default function HooksPanel({ hooks, setHooks, hookLoading, setHookLoadin

{hookDescription(hook, t)}

- {hook.available && hook.installMethod !== 'manual' && ( - // Enabling a hook is a secondary verb, not the one thing - // the page exists for, and there are several of them in a - // row — so no primary fill (SS4: one per screen), and never - // the danger fill it used to wear: danger red reports a - // state, it does not invite a click. - + )} + {hook.installed && shellNameForHook(hook.id) && ( + )} {hook.id === 'mitmproxy' && !hook.available && ( - )} {hasSteps && ( @@ -235,10 +188,15 @@ export default function HooksPanel({ hooks, setHooks, hookLoading, setHookLoadin plugin that brought it, on the Plugins page — here it was padding a list the operator reads to answer "is my shell recording". */} - - {hooks.length === 0 && ( + + {read.state.phase === 'loading' && (

{t('common.loading')}

)} + {read.state.phase === 'failed' &&
+

{t('firstRun.record.detectFailed')}

+

{read.state.error}

+ +
}
{mainHooks.map((hook) => renderRow(hook))}
@@ -252,6 +210,10 @@ export default function HooksPanel({ hooks, setHooks, hookLoading, setHookLoadin
)} + {connecting && !preparing && h.id === connecting)?.name ?? connecting }} + onInstalled={read.retry} onPrepare={() => setPreparing(true)} />} + {preparing && { setPreparing(false); read.retry() }} />} ) diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index ee4625a7..52c6271e 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -86,7 +86,7 @@ interface ResolvedExportPlan { attachments: ExportAttachmentRow[] } type ExportPlanResponse = { ok: true; plan: ResolvedExportPlan } | { ok: false; error: string } -type ExportPlanResult = { ok: true; planId: string; fingerprint: string; artifactPath: string; counts: ResolvedExportPlan['counts']; warnings: string[] } | { ok: false; error: string; planId?: string; fingerprint?: string } +type ExportPlanResult = { ok: true; planId: string; fingerprint: string; artifactPath: string; counts: ResolvedExportPlan['counts']; warnings: string[]; delivery: import('../../core/export-delivery').ExportDelivery } | { ok: false; error: string; planId?: string; fingerprint?: string } @@ -365,6 +365,7 @@ interface RedLogAPI { /** `destDir`: a folder returned by `pickExportDir`; omitted means the project's exports folder. */ executeExportPlan: (input: { planId: string; destDir?: string }) => Promise /** The project's default exports folder, or null with no project open. */ + recentExport: () => Promise<{ ok: true; delivery: import('../../core/export-delivery').ExportDelivery | null } | { ok: false; error: string }> exportDestination: () => Promise /** System folder picker. Only a folder chosen here is accepted as a destination. */ pickExportDir: () => Promise diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 51b889ed..ad68f3c1 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -851,12 +851,10 @@ "settings.wsl.networkNotConfigured": "Networking: not configured — WSL may not reach localhost", "settings.wsl.natFix": "Create %USERPROFILE%\\.wslconfig with [wsl2] networkingMode=mirrored, then wsl --shutdown", "settings.wsl.stopped": "Distro stopped — start it to manage hooks", - "settings.wsl.install": "Install", "settings.wsl.uninstall": "Remove", "settings.wsl.diagnose": "Diagnose", "settings.wsl.refresh": "Refresh", "settings.wsl.noDistros": "No WSL distributions found", - "settings.wsl.hookInstalled": "Hook installed", "settings.wsl.running": "Running", "settings.wsl.stoppedBadge": "Stopped", "settings.wsl.default": "default", @@ -864,10 +862,7 @@ "settings.wsl.notInstalled": "not installed", "settings.wsl.noShell": "no shell", "settings.wsl.diagTitle": "Diagnostics: {{distro}}", - "settings.wsl.installing": "Installing...", "common.retry": "Retry", - "settings.wsl.hookInstallFailed": "Could not install the hook in {{distro}}", - "settings.wsl.hookInstallFailedWhy": "The distro answered, but writing the shell profile did not succeed. It may be read-only, or another process holds it.", "settings.wsl.hookUninstalled": "Hook removed from {{distro}} ({{shell}})", "settings.wsl.hookUninstallFailed": "Could not remove the hook from {{distro}}", "settings.wsl.hookUninstallFailedWhy": "The distro answered, but editing the shell profile did not succeed. The hook is still active.", @@ -1378,7 +1373,7 @@ "settings.packRecord": "Record with this pack in this project", "settings.packMemberRecord": "Record this", "settings.clipboardScopeWarn": "Samples everything you copy on this machine, in or out of scope, for as long as the engagement is open.", - "settings.packRemoved": "This pack is disabled in Plugins, so it records nothing in any project.", + "settings.packRemoved": "Integration disabled on this computer", "settings.packHostMonitors": "Pack: Host monitors", "settings.packHostMonitorsHint": "Process, connection and file monitoring. Clipboard is separate — tick it below to record it.", "settings.packWindowsOutput": "Pack: Windows terminal output", @@ -1703,5 +1698,21 @@ "settings.agents.changeSource": "Choose transcript folder…", "settings.agents.defaultSource": "Use default location", "settings.screenshot.options": "Image options", - "settings.externalSetup": "Setup" + "settings.externalSetup": "Setup", + "export.completedTitle": "Export complete", + "export.recent": "Last export", + "export.another": "Export another", + "export.otherFormats": "Other formats", + "export.recentFailed": "Could not read the last export", + "export.artifactMissing": "The exported file has been removed", + "export.artifactUnavailable": "The exported file cannot be accessed", + "export.recentNotSaved": "The file was exported, but the last-result record could not be saved.", + "settings.shellInstalled": "Installed", + "settings.shellConnect": "Connect", + "settings.shellVerify": "Verify / repair", + "settings.dependenciesReady": "Dependencies ready", + "settings.wsl.readFailed": "Could not read WSL environments", + "settings.packReadFailed": "Could not read capture integrations", + "settings.packUnavailable": "Capture integration unavailable", + "settings.sourceRestore": "Enable integration" } diff --git a/src/renderer/src/i18n/zh-TW.json b/src/renderer/src/i18n/zh-TW.json index b32f1f9b..2b6a762b 100644 --- a/src/renderer/src/i18n/zh-TW.json +++ b/src/renderer/src/i18n/zh-TW.json @@ -851,12 +851,10 @@ "settings.wsl.networkNotConfigured": "網路模式:未設定 — WSL 可能無法連到 localhost", "settings.wsl.natFix": "建立 %USERPROFILE%\\.wslconfig 設定 [wsl2] networkingMode=mirrored,然後 wsl --shutdown", "settings.wsl.stopped": "發行版已停止 — 啟動後才能管理 hook", - "settings.wsl.install": "安裝", "settings.wsl.uninstall": "移除", "settings.wsl.diagnose": "診斷", "settings.wsl.refresh": "重新掃描", "settings.wsl.noDistros": "未找到 WSL 發行版", - "settings.wsl.hookInstalled": "Hook 已安裝", "settings.wsl.running": "執行中", "settings.wsl.stoppedBadge": "已停止", "settings.wsl.default": "預設", @@ -864,10 +862,7 @@ "settings.wsl.notInstalled": "未安裝", "settings.wsl.noShell": "無 shell", "settings.wsl.diagTitle": "診斷:{{distro}}", - "settings.wsl.installing": "安裝中...", "common.retry": "重試", - "settings.wsl.hookInstallFailed": "無法在 {{distro}} 安裝 hook", - "settings.wsl.hookInstallFailedWhy": "發行版有回應,但寫入 shell 設定檔沒有成功。可能是唯讀,或被其他程序占用。", "settings.wsl.hookUninstalled": "已從 {{distro}}({{shell}})移除 hook", "settings.wsl.hookUninstallFailed": "無法從 {{distro}} 移除 hook", "settings.wsl.hookUninstallFailedWhy": "發行版有回應,但修改 shell 設定檔沒有成功。hook 仍在作用中。", @@ -1378,7 +1373,7 @@ "settings.packRecord": "在這個專案啟用此 pack", "settings.packMemberRecord": "記錄這一項", "settings.clipboardScopeWarn": "在專案開啟期間,會取樣你在這台機器上複製的所有內容 —— 不分是否在範圍內。", - "settings.packRemoved": "此 pack 已在外掛頁停用,任何專案都不會記錄。", + "settings.packRemoved": "此整合已在這台電腦停用", "settings.packHostMonitors": "Pack:主機監看", "settings.packHostMonitorsHint": "行程、連線與檔案監看。剪貼簿另計——要在下方勾選才會記錄。", "settings.packWindowsOutput": "Pack:Windows 終端輸出", @@ -1703,5 +1698,21 @@ "settings.agents.changeSource": "選擇對話資料夾…", "settings.agents.defaultSource": "使用預設位置", "settings.screenshot.options": "影像選項", - "settings.externalSetup": "設定步驟" + "settings.externalSetup": "設定步驟", + "export.completedTitle": "匯出完成", + "export.recent": "最近一次匯出", + "export.another": "匯出另一份", + "export.otherFormats": "其他格式", + "export.recentFailed": "無法讀取最近匯出", + "export.artifactMissing": "匯出檔案已移除", + "export.artifactUnavailable": "無法存取匯出檔案", + "export.recentNotSaved": "檔案已匯出,但無法保存最近結果。", + "settings.shellInstalled": "已安裝", + "settings.shellConnect": "接入", + "settings.shellVerify": "驗證/修復", + "settings.dependenciesReady": "依賴工具已備妥", + "settings.wsl.readFailed": "無法讀取 WSL 環境", + "settings.packReadFailed": "無法讀取擷取整合", + "settings.packUnavailable": "擷取整合無法使用", + "settings.sourceRestore": "啟用整合" } diff --git a/src/renderer/src/lib/settingsSearchIndex.ts b/src/renderer/src/lib/settingsSearchIndex.ts index d679c2b7..a7ab1b78 100644 --- a/src/renderer/src/lib/settingsSearchIndex.ts +++ b/src/renderer/src/lib/settingsSearchIndex.ts @@ -10,34 +10,30 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.terminalClassNative', 'settings.terminalClassPty', 'settings.terminalClassEditedFromShell', - 'settings.hookInstalled', - 'settings.hookInstallFailed', - 'settings.hookFailedWhy', - 'settings.hookActive', + 'settings.shellInstalled', 'settings.hookManual', 'settings.hookNotFound', + 'settings.shellVerify', + 'settings.shellConnect', 'settings.hookDisable', - 'settings.hookEnable', - 'captureSetup.installing', 'captureSetup.install', 'settings.hookHideSetup', 'settings.hookOptionalSteps', 'settings.hookShowSetup', 'settings.hookManualNote', 'settings.hooksBuiltin', + 'firstRun.record.detectFailed', 'settings.otherPlatforms', 'settings.hookCopy', 'settings.hookRunHint', 'settings.hookRun', 'settings.hookTeardown', - 'settings.wsl.hookInstalled', - 'settings.wsl.hookInstallFailed', - 'settings.wsl.hookInstallFailedWhy', - 'settings.wsl.distroUnreachableWhy', 'settings.wsl.hookUninstalled', 'settings.wsl.hookUninstallFailed', 'settings.wsl.hookUninstallFailedWhy', + 'settings.wsl.distroUnreachableWhy', 'settings.wsl.diagnosticsFailed', + 'settings.wsl.readFailed', 'settings.wsl.title', 'settings.wsl.refresh', 'settings.wsl.noDistros', @@ -52,11 +48,68 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.wsl.stoppedBadge', 'settings.wsl.default', 'settings.wsl.stopped', - 'settings.wsl.installing', - 'settings.wsl.install', 'settings.wsl.uninstall', 'settings.wsl.diagnose', - 'settings.wsl.diagTitle' + 'settings.wsl.diagTitle', + 'firstRun.record.installing', + 'firstRun.record.detecting', + 'firstRun.record.blocked', + 'firstRun.record.reasonMissing', + 'captureSetup.openPreparation', + 'firstRun.record.adapterUnavailableWhy', + 'firstRun.record.adapterUnavailableHint', + 'firstRun.record.installFailed', + 'firstRun.record.retry', + 'firstRun.record.paste', + 'firstRun.record.waiting', + 'firstRun.record.timeoutTitle', + 'firstRun.record.reasonNewTab', + 'firstRun.record.reasonPolicy', + 'firstRun.record.adapterTitle', + 'firstRun.record.adapterDetected', + 'firstRun.record.adapterUnavailable', + 'firstRun.record.adapterRecoverable', + 'firstRun.policy.title', + 'firstRun.policy.why', + 'firstRun.policy.builtinOk', + 'firstRun.policy.fix', + 'firstRun.record.verified', + 'firstRun.record.scopeZsh', + 'firstRun.record.scopeBash', + 'firstRun.record.scope', + 'firstRun.record.outputPwsh', + 'firstRun.record.detailsHide', + 'firstRun.record.detailsShow', + 'firstRun.record.sessionHintSession', + 'firstRun.record.sessionHint', + 'firstRun.record.outputCheck', + 'firstRun.record.outputCheckSession', + 'firstRun.record.outputVerified', + 'firstRun.record.outputMetadataOnly', + 'firstRun.http.installPrereq', + 'captureSetup.installing', + 'captureSetup.installWait', + 'captureSetup.installElapsed', + 'captureSetup.installCancelled', + 'captureSetup.manual', + 'captureSetup.prepare', + 'firstRun.preflightFailedTitle', + 'firstRun.preflightFailedWhy', + 'captureSetup.checking', + 'captureSetup.missing', + 'captureSetup.webOnly', + 'captureSetup.skip', + 'firstRun.core.later', + 'captureSetup.exitsExplained', + 'settings.dependenciesReady', + 'settings.packWindowsOutput', + 'settings.powershellTranscriptEnableHint', + 'settings.packMemberRecord', + 'settings.packMemberOptIn', + 'settings.packReadFailed', + 'settings.sourceRestore', + 'settings.openPlugins', + 'settings.packRecord' ], captureControl: [ 'settings.screenshotGroup', @@ -87,9 +140,12 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.connectionMonitorGroup', 'settings.connectionMonitorEnableHint', 'settings.connectionMonitorSynNote', - 'settings.packWindowsOutput', - 'settings.powershellTranscriptEnableHint', - 'settings.otherPlatforms', + 'settings.packMemberRecord', + 'settings.packMemberOptIn', + 'settings.packReadFailed', + 'settings.sourceRestore', + 'settings.openPlugins', + 'settings.packRecord', 'settings.agents.pickWork', 'settings.agents.removeTitle', 'settings.agents.removeWhy', @@ -125,6 +181,7 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.externalNotRunning', 'settings.externalLastKnown', 'settings.externalSetup', + 'settings.otherPlatforms', 'settings.source.lastReceived', 'settings.source.neverReceived', 'settings.externalDesc.c2-tailers', @@ -183,7 +240,48 @@ export const SETTINGS_SEARCH_KEYS: Record = { 'settings.browserIgnoreCert', 'browser.launched', 'browser.failed', - 'browser.launch' + 'browser.launch', + 'settings.hookRun', + 'firstRun.http.verifiedVia', + 'firstRun.http.rejected', + 'firstRun.core.pending', + 'firstRun.http.captureBrowserCaveat', + 'firstRun.http.title', + 'firstRun.core.verified', + 'firstRun.http.preflightFailed', + 'firstRun.http.missing', + 'captureSetup.openPreparation', + 'firstRun.http.browserChoice', + 'firstRun.http.ownBrowser', + 'firstRun.http.captureBrowser', + 'firstRun.http.proxyLead', + 'firstRun.http.proxyHint', + 'firstRun.http.caDownload', + 'firstRun.http.caLink', + 'firstRun.http.verifyOwn', + 'firstRun.http.timeoutTitle', + 'firstRun.http.verifyInBrowser', + 'firstRun.http.newCheck', + 'firstRun.http.verifyFromTool', + 'firstRun.http.verifyNoInsecure', + 'httpCapture.failed', + 'firstRun.http.installPrereq', + 'captureSetup.installing', + 'captureSetup.install', + 'captureSetup.installWait', + 'captureSetup.installElapsed', + 'captureSetup.installCancelled', + 'captureSetup.manual', + 'captureSetup.prepare', + 'firstRun.preflightFailedTitle', + 'firstRun.preflightFailedWhy', + 'captureSetup.checking', + 'captureSetup.missing', + 'captureSetup.webOnly', + 'captureSetup.skip', + 'firstRun.core.later', + 'captureSetup.exitsExplained', + 'settings.dependenciesReady' ], scope: [ 'settings.scopeTest.unknown', diff --git a/src/renderer/src/lib/terminalActivation.ts b/src/renderer/src/lib/terminalActivation.ts index 7a4b88fb..077c41b8 100644 --- a/src/renderer/src/lib/terminalActivation.ts +++ b/src/renderer/src/lib/terminalActivation.ts @@ -98,7 +98,7 @@ interface DetectedHook { export function hostAdapterChoices(hooks: DetectedHook[], platform: string): AdapterChoice[] { const order = platform === 'win32' ? ['shell-powershell', 'shell-bash'] - : ['shell-zsh', 'shell-bash'] + : ['shell-zsh', 'shell-bash', ...hooks.filter(h => h.id === 'shell-powershell' && h.available).map(h => h.id)] const byId = new Map(hooks.map((h) => [h.id, h])) const out: AdapterChoice[] = [] for (const id of order) { diff --git a/test/capture-pack-group.test.tsx b/test/capture-pack-group.test.tsx index 4efcc391..de387f6d 100644 --- a/test/capture-pack-group.test.tsx +++ b/test/capture-pack-group.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { describe, it, expect, afterEach, vi } from 'vitest' -import { render, cleanup, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest' +import { render, cleanup, screen, fireEvent, waitFor } from '@testing-library/react' import CapturePackGroup, { PackMember } from '../src/renderer/src/components/settings/CapturePackGroup' import type { ConfigState } from '../src/renderer/src/components/settings/SettingsShared' @@ -9,20 +9,24 @@ const t = (k: string): string => k const cfg = (packs: ConfigState['packs']) => ({ packs } as unknown as ConfigState) describe('CapturePackGroup', () => { + beforeEach(() => Object.assign(window, { redlog: { plugins: { + list: vi.fn(async () => [{ id: 'pack-host-monitors', source: 'bundled', status: 'active' }]), + setEnabled: vi.fn(async () => []) + } } })) afterEach(() => cleanup()) - it('turns the pack on for the project, and shows member tuning only while on', () => { + it('turns the pack on for the project, and shows member tuning only while on', async () => { const seen: ConfigState[] = [] const { rerender } = render( - seen.push(c)} t={t}> + seen.push(c)} t={t}>

member tuning

) expect(screen.queryByText('member tuning')).toBeNull() - fireEvent.click(screen.getByTestId('pack-switch-hostMonitors')) + fireEvent.click(await screen.findByTestId('pack-switch-hostMonitors')) expect(seen.at(-1)!.packs).toEqual({ hostMonitors: true }) rerender( - {}} t={t}> + {}} t={t}>

member tuning

) @@ -75,36 +79,82 @@ describe('CapturePackGroup', () => { expect(seen.at(-1)!.packMembers).toEqual({ processMonitor: false }) }) - it('says a pack disabled in Plugins is removed, instead of offering a switch', () => { + it('says a pack disabled in Plugins is removed, instead of offering a switch', async () => { + vi.mocked(window.redlog.plugins.list).mockResolvedValue([{ id: 'pack-host-monitors', source: 'bundled', status: 'disabled' }]) render( - {}} t={t}> + {}} t={t}>

member tuning

) - expect(screen.getByTestId('pack-removed-hostMonitors')).toBeTruthy() + expect(await screen.findByTestId('pack-removed-hostMonitors')).toBeTruthy() expect(screen.queryByTestId('pack-switch-hostMonitors')).toBeNull() expect(screen.queryByText('member tuning')).toBeNull() }) // #435: the row that says the pack is disabled offers the way back, so the // operator does not have to find the page themselves. - it('offers the direct route to plugin settings when the pack is disabled there', () => { + it('offers the direct route to plugin settings when the pack is disabled there', async () => { + vi.mocked(window.redlog.plugins.list).mockResolvedValue([{ id: 'pack-host-monitors', source: 'bundled', status: 'disabled' }]) const onOpenPlugins = vi.fn() render( - {}} t={t} onOpenPlugins={onOpenPlugins}> + {}} t={t} onOpenPlugins={onOpenPlugins}>

member tuning

) - fireEvent.click(screen.getByTestId('pack-open-plugins-hostMonitors')) + fireEvent.click(await screen.findByTestId('pack-open-plugins-hostMonitors')) expect(onOpenPlugins).toHaveBeenCalledTimes(1) }) - it('offers no route when there is nowhere to go', () => { + it('offers no route when there is nowhere to go', async () => { + vi.mocked(window.redlog.plugins.list).mockResolvedValue([{ id: 'pack-host-monitors', source: 'bundled', status: 'disabled' }]) render( - {}} t={t}> + {}} t={t}>

member tuning

) expect(screen.queryByTestId('pack-open-plugins-hostMonitors')).toBeNull() }) + it('keeps loading and failed reads from offering a project recording switch; retry recovers', async () => { + vi.mocked(window.redlog.plugins.list).mockRejectedValueOnce(new Error('registry unreadable')) + render(

member tuning

) + expect(screen.queryByTestId('pack-switch-hostMonitors')).toBeNull() + expect((await screen.findByRole('alert')).textContent).toContain('registry unreadable') + expect(screen.queryByText('member tuning')).toBeNull() + fireEvent.click(screen.getByText('common.retry')) + await screen.findByTestId('pack-switch-hostMonitors') + expect(screen.getByText('member tuning')).toBeTruthy() + }) + it('restores only the disabled bundled integration, leaving project permissions untouched', async () => { + vi.mocked(window.redlog.plugins.list).mockResolvedValueOnce([{ id: 'pack-host-monitors', source: 'bundled', status: 'disabled' }]) + const setConfig = vi.fn() + render(

member tuning

) + fireEvent.click(await screen.findByTestId('pack-repair-hostMonitors')) + await waitFor(() => expect(window.redlog.plugins.setEnabled).toHaveBeenCalledWith('pack-host-monitors', true)) + const control = await screen.findByTestId('pack-switch-hostMonitors') as HTMLInputElement + expect(control.checked).toBe(false) + expect(setConfig).not.toHaveBeenCalled() + expect(screen.queryByText('member tuning')).toBeNull() + }) + it('does not pretend a missing or untrusted integration can be enabled', async () => { + vi.mocked(window.redlog.plugins.list).mockResolvedValue([{ id: 'pack-host-monitors', source: 'user', status: 'disabled' }]) + render() + await screen.findByText('settings.packUnavailable') + expect(screen.queryByTestId('pack-repair-hostMonitors')).toBeNull() + expect(screen.queryByTestId('pack-switch-hostMonitors')).toBeNull() + }) + + it('refreshes integration state on return without changing project capture or restoring it', async () => { + const setConfig = vi.fn() + render() + await screen.findByTestId('pack-switch-hostMonitors') + vi.mocked(window.redlog.plugins.list).mockResolvedValue([{ id: 'pack-host-monitors', source: 'bundled', status: 'disabled' }]) + fireEvent(window, new Event('focus')) + fireEvent(document, new Event('visibilitychange')) + await screen.findByTestId('pack-removed-hostMonitors') + expect(screen.queryByTestId('pack-switch-hostMonitors')).toBeNull() + expect(window.redlog.plugins.list).toHaveBeenCalledTimes(2) + expect(window.redlog.plugins.setEnabled).not.toHaveBeenCalled() + expect(setConfig).not.toHaveBeenCalled() + }) + }) diff --git a/test/export-attachment-list.test.tsx b/test/export-attachment-list.test.tsx index c16791db..791f74a7 100644 --- a/test/export-attachment-list.test.tsx +++ b/test/export-attachment-list.test.tsx @@ -35,7 +35,7 @@ afterEach(() => { cleanup(); vi.restoreAllMocks() }) describe('bundle preview: per-attachment selection (#222)', () => { it('lists each file with its targets, flags a cross-target cast, and re-resolves without an unticked one', async () => { const resolve = vi.fn(async (req: ExportRequest) => ({ ok: true as const, plan: plan(req.excludeAttachments ?? []) })) - ;(window as unknown as { redlog: unknown }).redlog = { data: { resolveExportPlan: resolve, executeExportPlan: vi.fn() } } + ;(window as unknown as { redlog: unknown }).redlog = { data: { recentExport: vi.fn(async () => ({ ok: true, delivery: null })), resolveExportPlan: resolve, executeExportPlan: vi.fn() } } render() fireEvent.click(screen.getByLabelText('Export')) fireEvent.change(screen.getByLabelText('Export format'), { target: { value: 'bundle' } }) diff --git a/test/export-destination-ipc.test.ts b/test/export-destination-ipc.test.ts index dd448e16..7c50f2f4 100644 --- a/test/export-destination-ipc.test.ts +++ b/test/export-destination-ipc.test.ts @@ -7,6 +7,7 @@ const { showOpenDialog } = vi.hoisted(() => ({ showOpenDialog: vi.fn() })) vi.mock('electron', () => ({ shell: { openPath: vi.fn(async () => ''), showItemInFolder: vi.fn() }, dialog: { showOpenDialog } })) import { initDB, closeDB } from '../src/core/db/index' +import { RecentExportStore } from '../src/main/services/recent-export' import { registerDataExportIpc } from '../src/main/ipc/data-export' import type { IpcContext } from '../src/main/ipc/types' import type { ProjectMeta } from '../src/core/project-manager' @@ -44,6 +45,7 @@ describe('export destination IPC', () => { let dir: string let out: string let active: ProjectMeta + let ctx: IpcContext let handlers: Map beforeEach(() => { @@ -54,11 +56,11 @@ describe('export destination IPC', () => { handlers = new Map() showOpenDialog.mockReset() const ipcMain = { handle: (name: string, fn: Handler) => { handlers.set(name, fn) } } - const ctx = { + ctx = { getActiveProject: () => active, getMainWindow: () => null, getOverlayWindow: () => null, getCurrentEngagementId: () => active.id, getCurrentOperatorId: () => null, send: () => undefined } as unknown as IpcContext - registerDataExportIpc(ipcMain as never, ctx, { planRegistry: new ExportPlanRegistry() }) + registerDataExportIpc(ipcMain as never, ctx, { planRegistry: new ExportPlanRegistry(), recentExports: new RecentExportStore(path.join(dir, 'machine-data')) }) }) afterEach(() => { vi.restoreAllMocks() @@ -75,6 +77,48 @@ describe('export destination IPC', () => { return r.plan.id } + it('remembers only a real successful delivery for the active project', async () => { + const executed = await handlers.get('data:executeExportPlan')?.({}, { planId: resolvePlan('json') } as never) as { ok: true; artifactPath: string } + expect(executed.ok).toBe(true) + const recent = () => handlers.get('data:recentExport')?.({}) + expect(await recent()).toMatchObject({ ok: true, delivery: { artifactPath: executed.artifactPath, format: 'json', availability: 'available' } }) + await handlers.get('data:executeExportPlan')?.({}, { planId: 'not-a-plan' } as never) + expect(await recent()).toMatchObject({ ok: true, delivery: { artifactPath: executed.artifactPath } }) + active = { ...active, id: 'another-project' } + expect(await recent()).toEqual({ ok: true, delivery: null }) + active = { ...active, id: 'eng-1' } + fs.rmSync(executed.artifactPath) + expect(await recent()).toMatchObject({ ok: true, delivery: { availability: 'missing' } }) + }) + + it('can reveal the exact completed custom artifact after restart, never its siblings', async () => { + showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [out] }) + await handlers.get('data:pickExportDir')?.({}) + const done = await handlers.get('data:executeExportPlan')?.({}, { planId: resolvePlan('json'), destDir: out } as never) as { ok: true; artifactPath: string } + expect(done.ok).toBe(true) + const elsewhere = path.join(out, 'unrelated.txt'); fs.writeFileSync(elsewhere, 'not an export') + registerDataExportIpc({ handle: (name: string, fn: Handler) => handlers.set(name, fn) } as never, ctx, { recentExports: new RecentExportStore(path.join(dir, 'machine-data')) }) + expect(await handlers.get('data:revealExport')?.({}, done.artifactPath as never)).toBe(true) + expect(await handlers.get('data:revealExport')?.({}, elsewhere as never)).toBe(false) + active = { ...active, id: 'other' } + expect(await handlers.get('data:revealExport')?.({}, done.artifactPath as never)).toBe(false) + }) + + it('reports metadata failure without losing a successful artifact or replacing the prior receipt', async () => { + const prior = await handlers.get('data:executeExportPlan')?.({}, { planId: resolvePlan('json') } as never) as { artifactPath: string } + const rename = fs.renameSync + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (String(to).includes('machine-data')) throw new Error('injected receipt refusal') + rename(from, to) + }) + const next = await handlers.get('data:executeExportPlan')?.({}, { planId: resolvePlan('json') } as never) as { ok: boolean; artifactPath: string; warnings: string[] } + expect(next.ok).toBe(true) + expect(next.warnings).toContain('recent-export-not-saved') + expect(fs.existsSync(next.artifactPath)).toBe(true) + expect(fs.existsSync(next.artifactPath + '.manifest.json')).toBe(true) + expect(await handlers.get('data:recentExport')?.({})).toMatchObject({ ok: true, delivery: { artifactPath: prior.artifactPath } }) + }) + it('reports the default folder', async () => { expect(await handlers.get('data:exportDestination')?.({})).toBe(path.join(dir, 'exports')) }) diff --git a/test/export-mask-toggle.test.tsx b/test/export-mask-toggle.test.tsx index 6f1929c5..2332e9aa 100644 --- a/test/export-mask-toggle.test.tsx +++ b/test/export-mask-toggle.test.tsx @@ -16,7 +16,7 @@ function installBridge(failResolve = false): void { lastOpts = undefined executeCalls = 0 ;(window as unknown as { redlog: unknown }).redlog = { - data: { + data: { recentExport: vi.fn(async () => ({ ok: true, delivery: null })), resolveExportPlan: async (request: { maskOutOfScope?: boolean }) => { lastOpts = request if (failResolve) return { ok: false, error: 'selection-failed' } @@ -40,7 +40,7 @@ function installBridge(failResolve = false): void { executeExportPlan: async () => { executeCalls++ return { - ok: true, planId: 'plan-1', fingerprint: 'abc', artifactPath: '/tmp/b', warnings: [], + ok: true, planId: 'plan-1', fingerprint: 'abc', artifactPath: '/tmp/b', warnings: [], delivery: { artifactPath: '/tmp/x', format: 'json', completedAt: 1700000000000, availability: 'available' }, counts: { examined: 10, included: 8, excludedDoNotExport: 1, excludedPersonal: 1, maskedOutOfScope: 2, sanitized: 3, attachmentsIncluded: 0, attachmentsMissing: 0, attachmentsUnattributed: 0, unsupported: 0 } } }, diff --git a/test/export-menu.test.tsx b/test/export-menu.test.tsx index 407d8a66..79d1c437 100644 --- a/test/export-menu.test.tsx +++ b/test/export-menu.test.tsx @@ -21,10 +21,13 @@ function plan(included: number): ResolvedExportPlan { } function install(resolveExportPlan: (request: ExportRequest) => Promise): void { + localStorage.setItem('redlog.export.format', 'json') ;(window as unknown as { redlog: unknown }).redlog = { data: { + recentExport: vi.fn(async () => ({ ok: true, delivery: null })), + revealExport: vi.fn(async () => true), resolveExportPlan, - executeExportPlan: vi.fn(async () => ({ ok: true, planId: 'plan-1', fingerprint: 'x', artifactPath: '/tmp/x', counts: plan(1).counts, warnings: [] })) + executeExportPlan: vi.fn(async () => ({ ok: true, planId: 'plan-1', fingerprint: 'x', artifactPath: '/tmp/x', counts: plan(1).counts, warnings: [], delivery: { artifactPath: '/tmp/x', format: 'json', completedAt: 1700000000000, availability: 'available' } })) } } } @@ -50,20 +53,6 @@ describe('ExportMenu plan preview', () => { afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks() }) - // The evidence bundle cannot scrub operator PII, so the plan resolver - // refuses the pair (unsupported-policy). The menu must not offer it as if it - // would work. - it('does not offer a format that cannot scrub PII once scrubbing is on', () => { - install(vi.fn()) - render() - fireEvent.click(screen.getByLabelText('Export')) - const bundle = (): HTMLOptionElement => screen.getByRole('option', { name: 'Evidence bundle (with verifier)' }) as HTMLOptionElement - expect(bundle()?.disabled).toBe(false) - fireEvent.click(screen.getByLabelText(/scrub operator pii/i)) - expect(bundle()?.disabled).toBe(true) - expect((screen.getByLabelText('Export range') as HTMLSelectElement).disabled).toBe(false) - }) - it('keeps confirmation disabled for an empty approved selection', async () => { install(async () => ({ ok: true, plan: plan(0) })) render() @@ -117,32 +106,6 @@ describe('ExportMenu plan preview', () => { describe('ExportMenu as a dialog', () => { afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks() }) - it('opens the preview as a modal dialog', async () => { - install(async () => ({ ok: true, plan: plan(2) })) - render() - openJson() - const dialog = await screen.findByRole('dialog') - expect(dialog.getAttribute('aria-modal')).toBe('true') - }) - - it('keeps the dialog and its preview when the run fails, and offers to recalculate', async () => { - const resolve = vi.fn(async () => ({ ok: true as const, plan: plan(2) })) - install(resolve) - ;(window as unknown as { redlog: { data: Record } }).redlog.data.executeExportPlan = - vi.fn(async () => ({ ok: false, error: 'disk full' })) - render() - openJson() - fireEvent.click(await screen.findByTestId('export-confirm')) - const err = await screen.findByTestId('export-run-error') - expect(err.textContent).toContain('disk full') - expect(screen.getByRole('dialog').textContent).toContain('Plan fingerprint') - // Confirming again would reuse a spent plan; the way on is to recalculate. - expect((screen.getByTestId('export-confirm') as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(screen.getByText('Recalculate and try again')) - await waitFor(() => expect(resolve).toHaveBeenCalledTimes(3)) - await waitFor(() => expect(screen.queryByTestId('export-run-error')).toBeNull()) - }) - it.each([ ['source-unavailable', 'changed between the preview and Confirm'], ['policy-changed', 'Settings that affect this export'] @@ -169,44 +132,31 @@ describe('ExportMenu as a dialog', () => { expect(screen.queryByTestId('export-run-error-meaning')).toBeNull() }) - it('closes on success', async () => { + it('can recover the previous result after reopening, and reports failed reveal', async () => { install(async () => ({ ok: true, plan: plan(2) })) - render() - openJson() - fireEvent.click(await screen.findByTestId('export-confirm')) - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) - }) -}) - -// A component declared inside another component's body is a new type on every -// render, so React unmounts and remounts its subtree each time — focus and -// state are lost on the keystroke that caused the render, and Fast Refresh -// cannot reconcile the old tree against the new one, which shows up as a panel -// that renders part of itself and stops. `Option` and `PreviewRow` were -// written that way and `Toggle` was added beside them. -describe('the menu renders all of itself', () => { - afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks() }) - - it('shows both choices and every format, not just the first row', () => { - install(vi.fn()) + const data = window.redlog.data + vi.mocked(data.recentExport).mockResolvedValue({ ok: true, delivery: { artifactPath: '/tmp/previous.json', format: 'json', completedAt: 1700000000000, availability: 'available' } }) + vi.mocked(data.revealExport).mockResolvedValue(false) render() fireEvent.click(screen.getByLabelText('Export')) - expect(screen.getByLabelText(/scrub operator pii/i)).toBeTruthy() - expect(screen.getByLabelText(/mask out-of-scope content using web scope/i)).toBeTruthy() - expect(screen.getByLabelText('Export range')).toBeTruthy() - expect(screen.getByText('Evidence bundle (with verifier)')).toBeTruthy() + fireEvent.click(await screen.findByTestId('export-recent')) + expect(screen.getByTestId('export-completed').textContent).toContain('/tmp/previous.json') + fireEvent.click(screen.getByTestId('export-reveal')) + await screen.findByTestId('export-reveal-error') + expect(screen.getByTestId('export-completed')).toBeTruthy() + fireEvent.click(screen.getByLabelText('Close')) + fireEvent.click(screen.getByLabelText('Export')) + expect(await screen.findByTestId('export-recent')).toBeTruthy() }) - it('keeps one toggle checked while the other is clicked', () => { - // The remount symptom, made observable: ticking one box re-renders the - // menu, and an inline component definition would rebuild the other from - // scratch. - install(vi.fn()) + it('retains the actual successful file until explicitly closed', async () => { + install(async () => ({ ok: true, plan: plan(2) })) render() - fireEvent.click(screen.getByLabelText('Export')) - const mask = screen.getByLabelText(/mask out-of-scope content using web scope/i) as HTMLInputElement - expect(mask.checked).toBe(true) - fireEvent.click(screen.getByLabelText(/scrub operator pii/i)) - expect((screen.getByLabelText(/mask out-of-scope content using web scope/i) as HTMLInputElement).checked).toBe(true) + openJson() + fireEvent.click(await screen.findByTestId('export-confirm')) + await screen.findByTestId('export-completed') + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.getByTestId('export-completed').textContent).toContain('/tmp/x') + expect(screen.queryByTestId('export-confirm')).toBeNull() }) }) diff --git a/test/export-preview-truth.test.tsx b/test/export-preview-truth.test.tsx index b0eff00f..57756899 100644 --- a/test/export-preview-truth.test.tsx +++ b/test/export-preview-truth.test.tsx @@ -30,9 +30,9 @@ const plan = (over: Partial = {}): ResolvedExportPlan => ({ function install(p: ResolvedExportPlan): void { ;(window as unknown as { redlog: unknown }).redlog = { - data: { + data: { recentExport: vi.fn(async () => ({ ok: true, delivery: null })), resolveExportPlan: vi.fn(async () => ({ ok: true as const, plan: p })), - executeExportPlan: vi.fn(async () => ({ ok: true as const, planId: p.id, fingerprint: 'x', artifactPath: '/tmp/x', counts: p.counts, warnings: [] })) + executeExportPlan: vi.fn(async () => ({ ok: true as const, planId: p.id, fingerprint: 'x', artifactPath: '/tmp/x', counts: p.counts, warnings: [], delivery: { artifactPath: '/tmp/x', format: 'json', completedAt: 1700000000000, availability: 'available' } })) }, config: { get: async () => ({}) } } diff --git a/test/export-range-format-dest.test.tsx b/test/export-range-format-dest.test.tsx index 845e2568..44c89ef3 100644 --- a/test/export-range-format-dest.test.tsx +++ b/test/export-range-format-dest.test.tsx @@ -27,10 +27,10 @@ function plan(): ResolvedExportPlan { let execute: ReturnType let pick: ReturnType function install(): void { - execute = vi.fn(async () => ({ ok: true, planId: 'plan-1', fingerprint: 'x', artifactPath: '/tmp/x', counts: plan().counts, warnings: [] })) + execute = vi.fn(async () => ({ ok: true, planId: 'plan-1', fingerprint: 'x', artifactPath: '/tmp/x', counts: plan().counts, warnings: [], delivery: { artifactPath: '/tmp/x', format: 'json', completedAt: 1700000000000, availability: 'available' } })) pick = vi.fn(async () => '/Volumes/evidence') ;(window as unknown as { redlog: unknown }).redlog = { - data: { + data: { recentExport: vi.fn(async () => ({ ok: true, delivery: null })), resolveExportPlan: vi.fn(async () => ({ ok: true, plan: plan() })), executeExportPlan: execute, exportDestination: vi.fn(async () => '/proj/exports'), @@ -83,15 +83,15 @@ describe('Export menu: range, remembered format, destination', () => { it('remembers the format picked in the dropdown', () => { draw(); open() - fireEvent.change(screen.getByLabelText('Export format'), { target: { value: 'ndjson' } }) + fireEvent.change(screen.getByLabelText('Export format'), { target: { value: 'other' } }); fireEvent.change(screen.getByLabelText('Other formats'), { target: { value: 'ndjson' } }) cleanup(); draw(); open() - expect((screen.getByLabelText('Export format') as HTMLSelectElement).value).toBe('ndjson') + expect((screen.getByLabelText('Other formats') as HTMLSelectElement).value).toBe('ndjson') }) it('ignores a stored format it does not offer', () => { localStorage.setItem('redlog.export.format', 'har') draw(); open() - expect((screen.getByLabelText('Export format') as HTMLSelectElement).value).toBe('json') + expect((screen.getByLabelText('Export format') as HTMLSelectElement).value).toBe('bundle') }) it('shows where the file will go, and sends a changed folder with the confirm', async () => { diff --git a/test/export-unified-dialog.test.tsx b/test/export-unified-dialog.test.tsx index eb52577a..ae4f50d5 100644 --- a/test/export-unified-dialog.test.tsx +++ b/test/export-unified-dialog.test.tsx @@ -21,7 +21,11 @@ function plan(request: ExportRequest, id = 'latest'): ResolvedExportPlan { let resolve: ReturnType let execute: ReturnType const open = (): void => { fireEvent.click(screen.getByLabelText('Export')) } -const format = (value: string): void => { fireEvent.change(screen.getByLabelText('Export format'), { target: { value } }) } +const format = (value: string): void => { + const secondary = !['bundle', 'har'].includes(value) + fireEvent.change(screen.getByLabelText('Export format'), { target: { value: secondary ? 'other' : value } }) + if (secondary) fireEvent.change(screen.getByLabelText('Other formats'), { target: { value } }) +} const range = (value: string): void => { fireEvent.change(screen.getByLabelText('Export range'), { target: { value } }) } const ready = async (): Promise => { await screen.findByTestId('export-confirm') } function draw(value = entry): ReturnType { @@ -30,10 +34,10 @@ function draw(value = entry): ReturnType { describe('one export dialog', () => { beforeEach(() => { - localStorage.clear() + localStorage.clear(); localStorage.setItem('redlog.export.format', 'json') resolve = vi.fn(async (request: ExportRequest) => ({ ok: true, plan: plan(request) })) - execute = vi.fn(async () => ({ ok: true, artifactPath: '/tmp/artifact' })) - ;(window as unknown as { redlog: unknown }).redlog = { data: { resolveExportPlan: resolve, executeExportPlan: execute } } + execute = vi.fn(async () => ({ ok: true, artifactPath: '/tmp/artifact', warnings: [], delivery: { artifactPath: '/tmp/artifact', format: 'json', completedAt: 1700000000000, availability: 'available' } })) + ;(window as unknown as { redlog: unknown }).redlog = { data: { recentExport: vi.fn(async () => ({ ok: true, delivery: null })), resolveExportPlan: resolve, executeExportPlan: execute } } }) afterEach(() => { cleanup(); vi.restoreAllMocks() }) @@ -142,7 +146,7 @@ describe('one export dialog', () => { expect(screen.getByRole('dialog')).toBeTruthy() release({ ok: false, error: 'disk full' }) await screen.findByTestId('export-run-error') - expect((screen.getByLabelText('Export format') as HTMLSelectElement).value).toBe('ndjson') + expect((screen.getByLabelText('Other formats') as HTMLSelectElement).value).toBe('ndjson') expect((screen.getByTestId('export-confirm') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(screen.getByText('Recalculate and try again')); await ready() expect(resolve.mock.lastCall?.[0]).toMatchObject({ format: 'ndjson', subset: selection }) diff --git a/test/hooks-panel-platform.test.tsx b/test/hooks-panel-platform.test.tsx index c5db66ef..35d3e71d 100644 --- a/test/hooks-panel-platform.test.tsx +++ b/test/hooks-panel-platform.test.tsx @@ -49,7 +49,7 @@ describe('the hooks panel on macOS', () => { hook('shell-powershell', { name: 'PowerShell adapter', available: false }), hook('shell-wsl', { name: 'WSL adapter', available: false }) ] - render( {}} hookLoading={null} setHookLoading={() => {}} t={(k) => k} />) + render( {}} t={(k) => k} />) const other = screen.getByTestId('hooks-other-platforms') expect(within(other).getByText('PowerShell adapter')).toBeTruthy() expect(within(other).getByText('WSL adapter')).toBeTruthy() @@ -62,7 +62,7 @@ describe('the hooks panel on macOS', () => { platform: 'darwin', hooks: { detect: async () => [], terminalPolicy: async () => null } } - render( {}} hookLoading={null} setHookLoading={() => {}} t={(k) => k} />) + render( {}} t={(k) => k} />) expect(screen.queryByTestId('hooks-other-platforms')).toBeNull() }) }) diff --git a/test/hooks-panel-terminal-policy.test.tsx b/test/hooks-panel-terminal-policy.test.tsx index 21ca3e0b..b05a9883 100644 --- a/test/hooks-panel-terminal-policy.test.tsx +++ b/test/hooks-panel-terminal-policy.test.tsx @@ -37,7 +37,7 @@ const draw = (terminalPolicy?: () => Promise<{ mode: 'auto' | 'manual'; native: } } render( - {}} hookLoading={null} setHookLoading={() => {}} t={t} /> + {}} t={t} /> ) } @@ -54,9 +54,19 @@ describe('the hooks panel says what the shell will do', () => { expect(text).toMatch(/Change it from the terminal/) }) - it('shows the mode the operator actually set, not the installed default', async () => { - draw(async () => ({ mode: 'auto', native: [], pty: [] })) - await waitFor(() => expect(screen.getByText(/recording from the first prompt/)).toBeTruthy()) + it('refreshes the actual mode on return without reinstalling a hook', async () => { + const policy = vi.fn().mockResolvedValueOnce({ mode: 'auto', native: [], pty: [] }) + .mockResolvedValue({ mode: 'manual', native: ['nc'], pty: ['ssh'] }) + draw(policy) + await screen.findByText(/recording from the first prompt/) + const install = vi.spyOn(window.redlog.hooks, 'install') + const detect = vi.spyOn(window.redlog.hooks, 'detect') + fireEvent(window, new Event('focus')) + fireEvent(document, new Event('visibilitychange')) + await screen.findByText(/not recording until redlog start/) + expect(policy).toHaveBeenCalledTimes(2) + expect(detect).toHaveBeenCalledTimes(1) + expect(install).not.toHaveBeenCalled() }) it('keeps the current mode visible while tool classifications are expandable', async () => { diff --git a/test/recent-export.test.ts b/test/recent-export.test.ts new file mode 100644 index 00000000..7f3f96b3 --- /dev/null +++ b/test/recent-export.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { RecentExportStore } from '../src/main/services/recent-export' + +let dir: string +beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-recent-export-')) }) +afterEach(() => { vi.restoreAllMocks(); fs.rmSync(dir, { recursive: true, force: true }) }) +const receipt = (artifactPath: string) => ({ artifactPath, format: 'json' as const, completedAt: 1_700_000_000_000 }) + +it('persists only one completed artifact per project/root on this machine', () => { + const storeDir = path.join(dir, 'machine-data') + const file = path.join(dir, 'evidence.json'); fs.writeFileSync(file, '{}') + new RecentExportStore(storeDir).remember('p', dir, receipt(file)) + const reopened = new RecentExportStore(storeDir) + expect(reopened.get('p', dir)).toEqual({ ...receipt(file), availability: 'available' }) + expect(reopened.get('other', dir)).toBeNull() + expect(reopened.get('p', path.join(dir, 'copied-project'))).toBeNull() + const next = path.join(dir, 'next.json'); fs.writeFileSync(next, '{}') + reopened.remember('p', dir, receipt(next)) + expect(fs.readdirSync(storeDir)).toHaveLength(1) + expect(reopened.get('p', dir)?.artifactPath).toBe(next) + if (process.platform !== 'win32') expect(fs.statSync(path.join(storeDir, fs.readdirSync(storeDir)[0])).mode & 0o777).toBe(0o600) +}) + +it('distinguishes a removed artifact and unreadable/corrupt metadata from no prior export', () => { + const storeDir = path.join(dir, 'machine-data'), file = path.join(dir, 'evidence.json') + const store = new RecentExportStore(storeDir) + expect(store.get('p', dir)).toBeNull() + fs.writeFileSync(file, '{}'); store.remember('p', dir, receipt(file)); fs.unlinkSync(file) + expect(store.get('p', dir)?.availability).toBe('missing') + fs.writeFileSync(path.join(storeDir, fs.readdirSync(storeDir)[0]), '{incomplete') + expect(() => store.get('p', dir)).toThrow('recent-export-unreadable') +}) + +it('keeps the earlier receipt if atomic replacement fails', () => { + const storeDir = path.join(dir, 'machine-data'), file = path.join(dir, 'first.json') + fs.writeFileSync(file, '{}') + const store = new RecentExportStore(storeDir); store.remember('p', dir, receipt(file)) + const fail = vi.spyOn(fs, 'renameSync').mockImplementation(() => { throw new Error('injected write refusal') }) + expect(() => store.remember('p', dir, receipt(path.join(dir, 'second.json')))).toThrow('injected write refusal') + expect(fail).toHaveBeenCalledOnce(); fail.mockRestore() + expect(store.get('p', dir)?.artifactPath).toBe(file) + expect(fs.readdirSync(storeDir)).toHaveLength(1) +}) + +it.each(['null', 'false', '{}', '[]'])('rejects valid JSON with an invalid receipt shape: %s', contents => { + const storeDir = path.join(dir, 'machine-data'), file = path.join(dir, 'artifact.json') + const store = new RecentExportStore(storeDir) + store.remember('p', dir, receipt(file)) + fs.writeFileSync(path.join(storeDir, fs.readdirSync(storeDir)[0]), contents) + expect(() => store.get('p', dir)).toThrow('recent-export-invalid') +}) diff --git a/test/record-terminal-adapter-selection.test.tsx b/test/record-terminal-adapter-selection.test.tsx index c3eadb94..ad378335 100644 --- a/test/record-terminal-adapter-selection.test.tsx +++ b/test/record-terminal-adapter-selection.test.tsx @@ -121,7 +121,7 @@ describe('record my terminal: adapter selection', () => { draw() const zsh = await screen.findByTestId('record-terminal-adapter-shell-zsh') expect(zsh.getAttribute('data-selected')).toBe('true') - expect(screen.getByTestId('record-terminal-adapter-detected-shell-zsh')).toBeTruthy() + expect(await screen.findByTestId('record-terminal-adapter-detected-shell-zsh')).toBeTruthy() await waitFor(() => expect(bridge.install).toHaveBeenCalledWith('shell-zsh')) fireEvent.click(screen.getByTestId('record-terminal-adapter-shell-bash')) @@ -131,6 +131,16 @@ describe('record my terminal: adapter selection', () => { expect(screen.getByTestId('record-terminal-adapter-shell-zsh').getAttribute('data-selected')).toBe('false') }) + it('keeps a manually selected Bash separate from the detected Zsh recommendation', async () => { + install() + draw(BASH_TARGET) + await waitFor(() => expect(bridge.install).toHaveBeenCalledWith('shell-bash')) + expect(screen.getByTestId('record-terminal-adapter-shell-bash').getAttribute('data-selected')).toBe('true') + expect(screen.getByTestId('record-terminal-adapter-detected-shell-zsh')).toBeTruthy() + expect(screen.queryByTestId('record-terminal-adapter-detected-shell-bash')).toBeNull() + expect(bridge.install).toHaveBeenCalledTimes(1) + }) + it('never lets the other adapter\'s canary mark the selection connected', async () => { install() draw() @@ -258,3 +268,12 @@ it('a WSL canary must identify the selected distro, not a host or another distro emit([{ id: 'right', timestamp: 1, agentType: 'shell', data: { subtype: 'command_end', command, shell: 'zsh', wsl_distro: 'Ubuntu' } }]) expect(await screen.findByTestId('record-terminal-verified')).toBeTruthy() }) + +it('offers PowerShell on a POSIX host only when the backend actually reports it available', async () => { + install({ hooks: [ZSH, BASH, hostHook('shell-powershell', true)] }) + draw() + const option = await screen.findByTestId('record-terminal-adapter-shell-powershell') + expect((option as HTMLButtonElement).disabled).toBe(false) + fireEvent.click(option) + await waitFor(() => expect(bridge.install).toHaveBeenCalledWith('shell-powershell')) +}) diff --git a/test/settings-autosave-flush.test.tsx b/test/settings-autosave-flush.test.tsx index ea37b105..4361f866 100644 --- a/test/settings-autosave-flush.test.tsx +++ b/test/settings-autosave-flush.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom import { StrictMode } from 'react' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Settings from '../src/renderer/src/components/Settings' +import { ToastContainer, toast } from '../src/renderer/src/components/Toast' import { I18nProvider } from '../src/renderer/src/i18n' import { closeProjectAfterSaves } from '../src/renderer/src/lib/pendingSaves' @@ -13,6 +14,7 @@ import { closeProjectAfterSaves } from '../src/renderer/src/lib/pendingSaves' const saves: Array<{ config: Record; opts?: { expectProjectId?: string } }> = [] let saveResult: boolean | Promise = true +let saveError: Error | null = null /** The order main saw the calls in: `save` and `close`. */ const calls: string[] = [] let getConfig: () => Promise @@ -41,6 +43,7 @@ function installBridge(): void { save: async (config: Record, opts?: { expectProjectId?: string }) => { calls.push('save') saves.push({ config, opts }) + if (saveError) throw saveError return saveResult } }, @@ -70,6 +73,7 @@ describe('the settings autosave does not lose a change on the way out', () => { saves.length = 0 calls.length = 0 saveResult = true + saveError = null getConfig = async () => structuredClone(CONFIG) installBridge() }) @@ -116,6 +120,31 @@ describe('the settings autosave does not lose a change on the way out', () => { await waitFor(() => expect(screen.queryByTestId('settings-save-saved')).not.toBeNull()) }) + it('clears only this project save error after a successful retry', async () => { + saveError = new Error('EACCES') + render() + act(() => { + toast('Other project save failed', { type: 'error', key: 'settings-save:proj-b' }) + toast('Evidence export failed', 'error') + }) + change(await ready()) + await screen.findByTestId('settings-save-failed', {}, { timeout: 3000 }) + expect(screen.getAllByRole('alert')).toHaveLength(3) + + saveError = null + saveResult = false + fireEvent.click(screen.getByRole('button', { name: /Retry|重試/ })) + await waitFor(() => expect(screen.queryByTestId('settings-save-failed')).not.toBeNull()) + expect(screen.getAllByRole('alert')).toHaveLength(3) + + saveResult = true + fireEvent.click(screen.getByRole('button', { name: /Retry|重試/ })) + await waitFor(() => expect(screen.queryByTestId('settings-save-saved')).not.toBeNull()) + expect(screen.getAllByRole('alert').map(x => x.textContent)).toHaveLength(2) + expect(screen.getByText('Other project save failed')).toBeTruthy() + expect(screen.getByText('Evidence export failed')).toBeTruthy() + }) + // #223: App awaited project.close() and only then unmounted Settings, so the // unmount flush reached main with no project open, was refused, and nothing // said so. These drive the same path App's close button takes. diff --git a/test/settings-source-workflow.test.tsx b/test/settings-source-workflow.test.tsx new file mode 100644 index 00000000..d7364ab2 --- /dev/null +++ b/test/settings-source-workflow.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor, act } from '@testing-library/react' +import { useState } from 'react' +import { I18nProvider } from '../src/renderer/src/i18n' +import WslPanel from '../src/renderer/src/components/WslPanel' +import HooksPanel from '../src/renderer/src/components/settings/HooksPanel' +import type { HookInfo } from '../src/renderer/src/components/settings/SettingsShared' + +const hook = { id: 'shell-zsh', name: 'Zsh', available: true, installed: false, builtin: true, installMethod: 'shell-source', description: '', hookFile: '' } as HookInfo +function Panel(): JSX.Element { + const [hooks, setHooks] = useState([]) + return k} /> +} +afterEach(cleanup) +describe('settings source connection', () => { + it('reads on entry and starts the shared terminal verification only after connect', async () => { + const install = vi.fn(async () => ({ success: true })) + Object.assign(window, { redlog: { + platform: 'darwin', hooks: { detect: vi.fn(async () => [hook]), install, terminalPolicy: async () => null }, + runtime: { preflight: async () => ({ checks: [], shell: { hookId: 'shell-zsh', name: 'zsh' } }) }, + events: { onNewBatch: () => () => {} } + } }) + render() + await screen.findByText('Zsh') + expect(install).not.toHaveBeenCalled() + fireEvent.click(screen.getByTestId('hook-connect-shell-zsh')) + await waitFor(() => expect(install).toHaveBeenCalledWith('shell-zsh')) + await screen.findByTestId('record-terminal-command') + expect(screen.queryByTestId('record-terminal-verified')).toBeNull() + expect(screen.getByTestId('record-terminal-flow').getAttribute('data-phase')).toBe('waiting-for-activation') + }) + it('shows a failed detection with retry instead of an endless loading state', async () => { + const detect = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue([hook]) + Object.assign(window, { redlog: { platform: 'darwin', hooks: { detect, terminalPolicy: async () => null } } }) + render() + await screen.findByRole('alert') + fireEvent.click(screen.getByTestId('hooks-read-retry')) + await screen.findByText('Zsh') + expect(detect).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('alert')).toBeNull() + }) +}) + +it('keeps the WSL activation attempt mounted while its installed state refreshes', async () => { + let finishRefresh!: (value: unknown) => void + const distro = { name: 'Kali', state: 'Running', version: 2, isDefault: true, shells: ['zsh'], hookStatus: { zsh: 'not-installed', bash: 'no-shell' } } + const listDistros = vi.fn().mockResolvedValueOnce([distro]).mockImplementationOnce(() => new Promise(r => { finishRefresh = r })) + const installHook = vi.fn(async () => ({ success: true })) + Object.assign(window, { redlog: { platform: 'win32', wsl: { listDistros, getNetworkMode: async () => 'mirrored', installHook }, events: { onNewBatch: () => () => {} } } }) + render( k} />) + fireEvent.click(await screen.findByText('settings.shellConnect')) + await screen.findByTestId('record-terminal-command') + await waitFor(() => expect(listDistros).toHaveBeenCalledTimes(2)) + await act(async () => finishRefresh([{ ...distro, hookStatus: { zsh: 'installed', bash: 'no-shell' } }])) + await screen.findByText('settings.shellVerify') + expect(screen.getByTestId('record-terminal-command')).toBeTruthy() + expect(installHook).toHaveBeenCalledTimes(1) +}) diff --git a/test/tailer-durability.test.ts b/test/tailer-durability.test.ts index 742b3907..84ce5442 100644 --- a/test/tailer-durability.test.ts +++ b/test/tailer-durability.test.ts @@ -184,13 +184,21 @@ describe('AI capture durable progress and privacy boundaries', () => { expect(saved.find(e => e.data.transcript_uuid === 'reply')!.data.transcript_parent_uuid).toBe('attachment') }) - it('omits a conversation created during pause even when discovery happens after resume', () => { + it.each([0, 1000])('omits a conversation discovered after resume when the filesystem clock leads Date.now by %i ms', async (clockLead) => { + if (clockLead) vi.spyOn(Date, 'now').mockReturnValue(Date.now() - clockLead) fs.writeFileSync(source, line('before')); start(); eventBus.pause() const later = path.join(dir, 'later.jsonl'); fs.writeFileSync(later, line('private', 'NEW_SESSION_WHILE_PAUSED')) eventBus.resume(); registerSession(adapter.agentKind, later) fs.appendFileSync(later, line('after')); catchUpSession(adapter.agentKind, 'later') expect(sidecars()).not.toContain('NEW_SESSION_WHILE_PAUSED') expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['after', 'before']) + restart() + // A genuinely new conversation after the persisted boundary still records. + await new Promise(resolve => setTimeout(resolve, 20)) + const fresh = path.join(dir, 'fresh.jsonl'); fs.writeFileSync(fresh, line('fresh')) + registerSession(adapter.agentKind, fresh) + expect(rows().map(e => e.data.transcript_uuid).sort()).toEqual(['after', 'before', 'fresh']) + expect(sidecars()).not.toContain('NEW_SESSION_WHILE_PAUSED') }) it('does not acknowledge a half-written per-message file and retries its later complete write', () => {