Skip to content

Commit 92ea468

Browse files
authored
Merge pull request #474 from Chris0Jeky/test/headed-manual-audit-pack
TST-25: Add opt-in headed manual-audit Playwright pack
2 parents 5e4fc0e + 4feb7ec commit 92ea468

File tree

3 files changed

+342
-1
lines changed

3 files changed

+342
-1
lines changed

docs/testing/MANUAL_AUDIT_PACK.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Manual Audit Pack
2+
3+
An opt-in headed Playwright suite for operator-visible debugging and pre-release sanity checks.
4+
5+
## Quick Start
6+
7+
```bash
8+
cd frontend/taskdeck-web
9+
npm run test:e2e:audit:headed
10+
```
11+
12+
With live LLM provider probes:
13+
14+
```bash
15+
TASKDECK_RUN_LIVE_LLM_TESTS=1 npm run test:e2e:audit:headed
16+
```
17+
18+
## What It Covers
19+
20+
### Core Loop (Home -> Inbox/Capture -> Review -> Board)
21+
22+
1. Home landing page renders correctly
23+
2. Capture item created and visible in Inbox
24+
3. Triage initiated from Inbox detail view
25+
4. Proposal appears in Review view after triage
26+
5. Approve and apply proposal
27+
6. Card appears on board with provenance links back to capture and proposal
28+
29+
### Advanced Checks
30+
31+
- Command palette search navigates to Inbox
32+
- Capture hotkey (`Ctrl+Shift+C`) opens modal and saves item
33+
- Board creation, column/card management, and filter panel
34+
35+
### Live LLM Provider Probe (opt-in)
36+
37+
- LLM health check (configured -> verified)
38+
- First chat turn returns a live (non-degraded) response
39+
40+
Gated behind `TASKDECK_RUN_LIVE_LLM_TESTS=1`. Skipped by default.
41+
42+
## Screenshots
43+
44+
Every test step captures a numbered screenshot to the Playwright output directory. These are useful for visual regression comparison, audit trails, and debugging.
45+
46+
Screenshots are saved as `01-home.png`, `02-inbox-with-capture.png`, etc. in the test output path (typically `test-results/`).
47+
48+
## When to Use
49+
50+
| Scenario | Use this pack? |
51+
|----------|---------------|
52+
| Local operator audit before release | Yes |
53+
| Visual debugging a UI regression | Yes |
54+
| Pre-demo sanity check (quick) | Yes |
55+
| Full stakeholder demo recording | No -- use `stakeholder-demo.spec.ts` with `TASKDECK_RUN_DEMO=1` |
56+
| CI smoke gate | No -- use `npm run test:e2e` (default headless) |
57+
| Live LLM provider verification | Yes, with `TASKDECK_RUN_LIVE_LLM_TESTS=1` |
58+
59+
## How It Differs from Other E2E Packs
60+
61+
- **Default smoke (`npm run test:e2e`)**: Headless, fast, runs in CI. Tests individual features in isolation.
62+
- **Stakeholder demo recorder (`stakeholder-demo.spec.ts`)**: Requires seeded demo data, captures video, designed for external presentation. Opt-in via `TASKDECK_RUN_DEMO=1`.
63+
- **Manual audit pack (`npm run test:e2e:audit:headed`)**: Headed with slow motion (250ms), captures screenshots at each milestone, covers the full capture-review-board loop end-to-end. Designed for operator debugging and quick visual audits. No demo seed required.
64+
65+
## CI Exclusion
66+
67+
All tests in `manual-audit.spec.ts` are gated behind `TASKDECK_RUN_AUDIT=1`. When `npm run test:e2e` runs in CI, the env var is unset and all audit tests are skipped. The dedicated `npm run test:e2e:audit:headed` script sets the env var automatically.
68+
69+
## Configuration
70+
71+
The pack uses the standard `playwright.config.ts` with these test-level overrides:
72+
73+
- `screenshot: 'on'` -- always capture screenshots
74+
- `trace: 'retain-on-failure'` -- trace files kept on failure for debugging
75+
- `launchOptions.slowMo: 250` -- 250ms delay between actions for visual clarity
76+
- `--headed` -- browser visible (set via npm script)
77+
- `--reporter=line` -- compact output for terminal readability

frontend/taskdeck-web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"test:ui": "vitest --ui",
2424
"test:coverage": "node -e \"require('fs').mkdirSync('test-results',{recursive:true})\" && vitest run --coverage --reporter=default --reporter=junit --outputFile.junit=./test-results/vitest.coverage.junit.xml",
2525
"test:e2e": "playwright test",
26-
"test:e2e:audit:headed": "playwright test tests/e2e/automation-ops.spec.ts tests/e2e/capture-loop.spec.ts tests/e2e/live-llm.spec.ts --headed --reporter=line",
26+
"test:e2e:audit:headed": "node -e \"process.env.TASKDECK_RUN_AUDIT='1';require('child_process').execSync('npx playwright test tests/e2e/manual-audit.spec.ts --headed --reporter=line',{stdio:'inherit',env:process.env})\"",
2727
"test:e2e:concurrency": "playwright test tests/e2e/concurrency.spec.ts --reporter=line",
2828
"test:e2e:live-llm:headed": "playwright test tests/e2e/live-llm.spec.ts --headed --reporter=line",
2929
"test:e2e:headed": "playwright test --headed"
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* Manual audit pack — opt-in headed Playwright suite for operator-visible debugging.
3+
*
4+
* Covers the core Home -> Inbox/Capture -> Review -> Board loop with screenshots
5+
* at each milestone, plus selected advanced checks (command palette, filter panel,
6+
* board settings lifecycle).
7+
*
8+
* Usage:
9+
* npm run test:e2e:audit:headed
10+
*
11+
* Live-provider probes (optional):
12+
* TASKDECK_RUN_LIVE_LLM_TESTS=1 npm run test:e2e:audit:headed
13+
*
14+
* This pack is NOT part of required CI. It is intended for local operator audits,
15+
* pre-release sanity checks, and visual debugging sessions.
16+
*
17+
* Gated behind TASKDECK_RUN_AUDIT=1. The npm script sets this automatically:
18+
* npm run test:e2e:audit:headed
19+
*/
20+
21+
import { expect, test } from '@playwright/test'
22+
import { parseTrueishEnv } from '../../scripts/demo-shared.mjs'
23+
import { registerAndAttachSession, type AuthResult } from './support/authSession'
24+
import { createBoardWithColumn } from './support/boardHelpers'
25+
import {
26+
createCaptureItem,
27+
listBoardCards,
28+
waitForCardWithTitle,
29+
waitForProposalCreated,
30+
} from './support/captureFlow'
31+
32+
const runAudit = parseTrueishEnv(process.env.TASKDECK_RUN_AUDIT)
33+
34+
test.use({
35+
screenshot: 'on',
36+
trace: 'retain-on-failure',
37+
launchOptions: {
38+
slowMo: 250,
39+
},
40+
})
41+
42+
let auth: AuthResult
43+
44+
test.beforeEach(async ({ page, request }) => {
45+
auth = await registerAndAttachSession(page, request, 'audit')
46+
})
47+
48+
test.describe('Core loop: Home -> Inbox/Capture -> Review -> Board', () => {
49+
test.skip(!runAudit, 'Set TASKDECK_RUN_AUDIT=1 or use npm run test:e2e:audit:headed')
50+
test('full capture-triage-review-apply loop with screenshots', async ({ page, request }, testInfo) => {
51+
// Step 1: Home landing
52+
await page.goto('/workspace/home')
53+
await expect(page.getByRole('heading', { name: 'Home', exact: true })).toBeVisible()
54+
await page.screenshot({ path: testInfo.outputPath('01-home.png'), fullPage: true })
55+
56+
// Step 2: Create board with column via API
57+
const seed = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`
58+
const boardId = await createBoardWithColumn(request, auth, seed, {
59+
boardNamePrefix: 'Audit Board',
60+
description: 'manual audit e2e board',
61+
columnNamePrefix: 'Inbox',
62+
})
63+
64+
// Step 3: Create capture item via API
65+
const checklistTaskTitle = `Audit card ${seed}`
66+
const captureText = `- [ ] ${checklistTaskTitle}`
67+
const createdCapture = await createCaptureItem(request, auth, boardId, captureText)
68+
const captureId = createdCapture.id
69+
70+
// Step 4: Navigate to Inbox and verify capture item
71+
await page.goto('/workspace/inbox')
72+
await expect(page.getByRole('heading', { name: 'Inbox', level: 1 })).toBeVisible()
73+
const captureRow = page.locator('[data-testid="inbox-item"]').filter({ hasText: checklistTaskTitle }).first()
74+
await expect(captureRow).toBeVisible()
75+
await page.screenshot({ path: testInfo.outputPath('02-inbox-with-capture.png'), fullPage: true })
76+
77+
// Step 5: Triage capture item
78+
await captureRow.click()
79+
const triageButton = page.locator('.td-inbox-detail__actions button').filter({ hasText: 'Start Triage' }).first()
80+
await expect(triageButton).toBeVisible()
81+
await page.screenshot({ path: testInfo.outputPath('03-inbox-detail-pre-triage.png'), fullPage: true })
82+
await triageButton.click()
83+
84+
// Step 6: Wait for proposal creation
85+
const triagedCapture = await waitForProposalCreated(request, auth, captureId)
86+
const proposalId = triagedCapture.provenance?.proposalId
87+
expect(proposalId).toBeTruthy()
88+
89+
// Verify no card created yet (proposal-first)
90+
const cardsAfterTriage = await listBoardCards(request, auth, boardId)
91+
expect(cardsAfterTriage.length).toBe(0)
92+
93+
// Step 7: Navigate to proposal in Review
94+
await page.getByRole('button', { name: 'Refresh Detail' }).click()
95+
const openProposalButton = page.getByRole('button', { name: 'Open in Review' })
96+
await expect(openProposalButton).toBeVisible()
97+
await openProposalButton.click()
98+
99+
await expect(page).toHaveURL(new RegExp(`/workspace/review\\?boardId=${boardId}#proposal-${proposalId}`))
100+
const proposalCard = page.locator(`#proposal-${proposalId}`)
101+
await expect(proposalCard).toBeVisible()
102+
await page.screenshot({ path: testInfo.outputPath('04-review-proposal.png'), fullPage: true })
103+
104+
// Step 8: Approve proposal
105+
await proposalCard.getByRole('button', { name: 'Approve for board' }).click()
106+
await expect(proposalCard.getByText('Approved')).toBeVisible()
107+
await page.screenshot({ path: testInfo.outputPath('05-review-approved.png'), fullPage: true })
108+
109+
// Step 9: Apply proposal to board
110+
page.once('dialog', (dialog) => dialog.accept())
111+
await proposalCard.getByRole('button', { name: 'Apply to board' }).click()
112+
await expect(proposalCard.getByText('Applied')).toBeVisible()
113+
await page.screenshot({ path: testInfo.outputPath('06-review-applied.png'), fullPage: true })
114+
115+
// Step 10: Verify card on board
116+
const createdCard = await waitForCardWithTitle(request, auth, boardId, checklistTaskTitle)
117+
118+
await page.goto(`/workspace/boards/${boardId}`)
119+
const card = page.locator('[data-card-id]').filter({ hasText: createdCard.title }).first()
120+
await expect(card).toBeVisible()
121+
await page.screenshot({ path: testInfo.outputPath('07-board-with-card.png'), fullPage: true })
122+
123+
// Step 11: Open card and verify provenance links
124+
await card.getByRole('heading', { name: createdCard.title, exact: true }).click()
125+
await expect(page.getByRole('heading', { name: 'Edit Card' })).toBeVisible()
126+
await expect(page.getByText('Capture Origin')).toBeVisible()
127+
await expect(page.getByRole('link', { name: 'Open Capture' })).toHaveAttribute(
128+
'href',
129+
`/workspace/inbox?boardId=${boardId}#capture-${captureId}`,
130+
)
131+
await expect(page.getByRole('link', { name: 'Open Proposal' })).toHaveAttribute(
132+
'href',
133+
`/workspace/review?boardId=${boardId}#proposal-${proposalId}`,
134+
)
135+
await page.screenshot({ path: testInfo.outputPath('08-card-provenance.png'), fullPage: true })
136+
})
137+
})
138+
139+
test.describe('Advanced checks', () => {
140+
test.skip(!runAudit, 'Set TASKDECK_RUN_AUDIT=1 or use npm run test:e2e:audit:headed')
141+
142+
test('command palette search navigates to inbox', async ({ page }, testInfo) => {
143+
await page.goto('/workspace/boards')
144+
await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible()
145+
146+
await page.keyboard.press('Control+K')
147+
const palette = page.getByRole('dialog', { name: 'Command palette' })
148+
await expect(palette).toBeVisible()
149+
await page.screenshot({ path: testInfo.outputPath('09-command-palette.png'), fullPage: true })
150+
151+
const paletteInput = palette.getByPlaceholder('Type a command or search...')
152+
await paletteInput.fill('inbox')
153+
await paletteInput.press('Enter')
154+
155+
await expect(page).toHaveURL(/\/workspace\/inbox$/)
156+
await page.screenshot({ path: testInfo.outputPath('10-inbox-from-palette.png'), fullPage: true })
157+
})
158+
159+
test('capture hotkey opens modal and saves item', async ({ page }, testInfo) => {
160+
await page.goto('/workspace/boards')
161+
await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible()
162+
163+
const captureText = `Audit capture ${Date.now()}`
164+
165+
await page.keyboard.press('Control+Shift+C')
166+
const captureModal = page.getByRole('dialog', { name: 'Capture item' })
167+
await expect(captureModal).toBeVisible()
168+
await page.screenshot({ path: testInfo.outputPath('11-capture-modal.png'), fullPage: true })
169+
170+
await captureModal.getByPlaceholder('Capture a thought, task, or follow-up...').fill(captureText)
171+
await captureModal.getByPlaceholder('Capture a thought, task, or follow-up...').press('Control+Enter')
172+
173+
await expect(page).toHaveURL(/\/workspace\/inbox$/)
174+
await expect(page.locator('.td-inbox-row__excerpt').first()).toContainText(captureText)
175+
await page.screenshot({ path: testInfo.outputPath('12-inbox-after-capture.png'), fullPage: true })
176+
})
177+
178+
test('board create, column, card, and filter panel', async ({ page }, testInfo) => {
179+
const boardName = `Audit Filter Board ${Date.now()}`
180+
const columnName = `To Do ${Date.now()}`
181+
const matchingCard = `Alpha ${Date.now()}`
182+
const hiddenCard = `Beta ${Date.now()}`
183+
184+
// Create board
185+
await page.goto('/workspace/boards')
186+
await page.getByRole('button', { name: '+ New Board' }).click()
187+
await page.getByPlaceholder('Board name').fill(boardName)
188+
await page.getByRole('button', { name: 'Create', exact: true }).click()
189+
await expect(page).toHaveURL(/\/workspace\/boards\/[a-f0-9-]+$/)
190+
await expect(page.getByRole('heading', { name: boardName })).toBeVisible()
191+
await page.screenshot({ path: testInfo.outputPath('13-new-board.png'), fullPage: true })
192+
193+
// Add column
194+
await page.getByRole('button', { name: '+ Add Column' }).click()
195+
await page.getByPlaceholder('Column name').fill(columnName)
196+
await page.getByRole('button', { name: 'Create', exact: true }).click()
197+
await expect(page.getByRole('heading', { name: columnName, exact: true })).toBeVisible()
198+
199+
// Add cards
200+
const column = page.locator('[data-column-id]')
201+
.filter({ has: page.getByRole('heading', { name: columnName, exact: true }) })
202+
.first()
203+
204+
for (const cardTitle of [matchingCard, hiddenCard]) {
205+
await column.getByRole('button', { name: 'Add Card' }).click()
206+
await column.getByPlaceholder('Enter card title...').fill(cardTitle)
207+
const createCardResponse = page.waitForResponse((response) =>
208+
response.request().method() === 'POST'
209+
&& /\/api\/boards\/[a-f0-9-]+\/cards$/i.test(response.url())
210+
&& response.ok())
211+
await column.getByRole('button', { name: 'Add', exact: true }).click()
212+
await createCardResponse
213+
await expect(page.locator('[data-card-id]').filter({ hasText: cardTitle }).first()).toBeVisible()
214+
}
215+
216+
await page.screenshot({ path: testInfo.outputPath('14-board-with-cards.png'), fullPage: true })
217+
218+
// Filter panel
219+
await page.keyboard.press('f')
220+
await expect(page.getByRole('heading', { name: 'Filter Cards' })).toBeVisible()
221+
await page.getByPlaceholder('Search cards...').fill(matchingCard)
222+
await expect(page.locator('[data-card-id]:visible')).toHaveCount(1)
223+
await expect(page.locator('[data-card-id]').filter({ hasText: matchingCard })).toBeVisible()
224+
await page.screenshot({ path: testInfo.outputPath('15-filter-active.png'), fullPage: true })
225+
})
226+
})
227+
228+
test.describe('Live LLM provider probe', () => {
229+
test.skip(!runAudit, 'Set TASKDECK_RUN_AUDIT=1 or use npm run test:e2e:audit:headed')
230+
test.skip(
231+
!parseTrueishEnv(process.env.TASKDECK_RUN_LIVE_LLM_TESTS),
232+
'Set TASKDECK_RUN_LIVE_LLM_TESTS=1 to run the opt-in live-provider probe.',
233+
)
234+
235+
test('live LLM health check and first chat turn', async ({ page }, testInfo) => {
236+
await page.goto('/workspace/automations/chat')
237+
await expect(page.locator('[data-llm-health-state="configured"]')).toBeVisible()
238+
await expect(page.getByText('Live LLM configured')).toBeVisible()
239+
await page.screenshot({ path: testInfo.outputPath('16-llm-configured.png'), fullPage: true })
240+
241+
await page.getByRole('button', { name: 'Verify LLM' }).click()
242+
await expect(page.locator('[data-llm-health-state="verified"]')).toBeVisible({ timeout: 30_000 })
243+
await expect(page.getByText('Live LLM verified')).toBeVisible()
244+
await page.screenshot({ path: testInfo.outputPath('17-llm-verified.png'), fullPage: true })
245+
246+
const probeToken = `AUDIT_LLM_PROBE_${Date.now()}`
247+
248+
await page.getByPlaceholder('Session title').fill(`Audit LLM ${Date.now()}`)
249+
await page.getByRole('button', { name: 'Create Session' }).click()
250+
251+
await page.getByPlaceholder('Describe an automation instruction...').fill(
252+
`Reply with exactly two lines. Line 1: ${probeToken}. Line 2: Wednesday.`,
253+
)
254+
await page.getByRole('button', { name: 'Send Message' }).click()
255+
256+
const assistantMessage = page
257+
.locator('.td-message')
258+
.filter({ has: page.locator('.td-message-role', { hasText: 'Assistant' }) })
259+
.last()
260+
const assistantContent = assistantMessage.locator('.td-message-content')
261+
await expect(assistantContent).toContainText(probeToken, { timeout: 30_000 })
262+
await page.screenshot({ path: testInfo.outputPath('18-llm-response.png'), fullPage: true })
263+
})
264+
})

0 commit comments

Comments
 (0)