Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/UIUX-STANDARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
## 9. 錯誤、狀態與資料量

- **錯誤訊息三段式**:發生什麼事(中文)+ 為什麼 + 一顆動作;原始錯誤收在「詳細」折疊區
- **Toast**:錯誤不自動消失;同類合併計數;上限 3 則;容器需 `aria-live="polite"`(錯誤用 `assertive`)
- **Toast**:錯誤不因逾時自動消失;同一操作確認重試成功後,移除對應錯誤,不清掉其他操作或專案的通知;同類合併計數;上限 3 則;容器需 `aria-live="polite"`(錯誤用 `assertive`)
- **持續性故障**分兩層固定在狀態列左側:〈需要注意 N〉只收影響證據的(抓取中斷、錨定失敗、鏈斷、DB 寫入錯誤、檔案遺失)—— **不可忽略**、紅點;其餘收〈待處理〉—— 可忽略、灰色、不寫稽核
- **範圍違規不進〈需要注意〉**。它不是故障,也可能是刻意的;側欄徽章與狀態列已經夠明顯。〈需要注意〉只收「系統沒在正常運作」
- **時間**:一律 24 小時。相對時間**只用於新鮮度欄位**(最後一次抓取、3 秒前);事件本身、時間軸軸標、逐字稿、匯出一律絕對時間,匯出用 ISO 8601
Expand Down
6 changes: 6 additions & 0 deletions docs/domain/SPEC-capture-source-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions docs/domain/SPEC-export-event-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
85 changes: 66 additions & 19 deletions e2e/capture-settings-interaction.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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')
Expand All @@ -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 [
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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')
Expand Down
48 changes: 41 additions & 7 deletions e2e/export-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -113,15 +146,16 @@ 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)
await error.getByRole('button').click()
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'))
Expand Down
3 changes: 2 additions & 1 deletion e2e/external-session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading
Loading