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
10 changes: 7 additions & 3 deletions docs/DESIGN-core-and-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,9 +382,13 @@ recordings.
to 27 when connection capture added one. Three groups still need a scope
ruling rather than a merge: the deconfliction webhook, operator tokens, and
the MCP server.
- Timeline view modes — the audit-mode shape does not survive §1, but the
eight flat toggles are still a real problem.
- 18 lanes.
- ~~Timeline view modes / the eight flat toggles.~~ **Done 2026-08-24.** The
three-view-modes attempt was reverted (it invented personas); the toggles are
now grouped by effect, with the low-frequency view/audit controls (session
dividers, timezone, auditor view) behind one "More" control.
- ~~18 lanes.~~ **Done 2026-08-24.** Banded by capture group (commands /
traffic / artifacts / signals), collapsible; the timeline opens as three or
four bands instead of eighteen lanes.
- ~~The plugin marketplace, publisher trust and revocation lists.~~
**Removed 2026-08-22.** Browsing a registry, pinning publishers by Ed25519
fingerprint, and reading revocation lists are the machinery of *distributing*
Expand Down
7 changes: 7 additions & 0 deletions e2e/timeline-geometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ test.describe.serial('timeline geometry + startup gates', () => {

// ---------------------------------------------------------------- P0-2
test('lane stack scrolls vertically instead of clipping (v0.9.4 P0-2)', async () => {
// Lanes now open banded (§6). Expand every band so all 18 lanes show, which
// is the many-rows overflow scenario this test exists for.
for (const b of ['commands', 'traffic', 'artifacts', 'signals']) {
const chip = page.locator(`[data-testid="timeline-band-${b}"]`)
if (await chip.count()) await chip.click()
}
await page.waitForTimeout(200)
const geom = await page.evaluate(() => {
const outer = document.querySelector('div.overflow-y-auto.min-h-0') as HTMLElement | null
if (!outer) return null
Expand Down
69 changes: 69 additions & 0 deletions e2e/timeline-lane-bands.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { MAIN_ENTRY, REPO_ROOT, openTestProject, openView } from './helpers'

// docs/DESIGN-core-and-capture.md §6: the 18 lanes banded by capture group.
// The timeline opens as capture-group bands (collapsed), each expandable to its
// lanes, instead of a wall of eighteen. This is the product's most complex
// screen, so this verifies the real render: bands by default, expand reveals
// lanes, and the events are actually drawn (a band that shows no dots would be
// worse than eighteen lanes).

let app: ElectronApplication
let page: Page

test.describe.serial('timeline lane bands', () => {
test.beforeAll(async () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'redlog-bands-'))
app = await electron.launch({ args: [MAIN_ENTRY], cwd: REPO_ROOT,
env: { ...process.env, NODE_ENV: 'test', HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_E2E: '1' } })
page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded')
await page.evaluate(() => localStorage.setItem('redlog-locale', 'en'))
await openTestProject(page, 'lane-bands')
await page.waitForTimeout(1200)
const base = `http://127.0.0.1:${readFileSync(join(tmpHome, '.redlog', 'api-port'), 'utf-8').trim()}`
const token = readFileSync(join(tmpHome, '.redlog', 'api-token'), 'utf-8').trim()
const post = (agent_type: string, data: Record<string, unknown>): Promise<Response> =>
fetch(`${base}/api/events/seed`, { method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ agent_type, data }) })
// one event in each of the four bands
await post('shell', { subtype: 'command_end', command: 'whoami' }) // commands
await post('scanner', { subtype: 'connection', proto: 'tcp', remote_addr: '10.0.0.9', remote_port: 445 }) // traffic
await post('screenshot', { trigger: 'manual' }) // artifacts
await post('marker', { title: 'note', severity: 'info' }) // signals
await page.waitForTimeout(600)
await openView(page, 'timeline')
// Wait for the band row to render rather than a fixed sleep — under full-
// suite load the render can lag past a fixed timeout.
await page.locator('[data-testid="timeline-band-commands"]').waitFor({ state: 'visible', timeout: 10_000 })
})
test.afterAll(async () => { await app?.close() })

test('opens as capture-group bands, not eighteen lanes', async () => {
await expect(page.locator('[data-testid="timeline-band-commands"]')).toBeVisible()
await expect(page.locator('[data-testid="timeline-band-traffic"]')).toBeVisible()
await expect(page.locator('[data-testid="timeline-band-artifacts"]')).toBeVisible()
await expect(page.locator('[data-testid="timeline-band-signals"]')).toBeVisible()
// The band rows collapse many lanes into four; far fewer than 18 rows.
const bandRows = await page.locator('[data-testid^="timeline-band-"]').count()
expect(bandRows).toBeLessThanOrEqual(4)
})

test('the collapsed bands still draw their events', async () => {
// A band that hid its dots would defeat the point. Dots render in the track.
const dots = await page.locator('[data-timeline-event]').count()
expect(dots, 'no events rendered in the collapsed bands').toBeGreaterThan(0)
})

test('clicking a band expands it into its lanes', async () => {
await page.locator('[data-testid="timeline-band-commands"]').click()
// The commands band is now gone (expanded); its shell lane label shows.
await expect(page.locator('[data-testid="timeline-band-commands"]')).toHaveCount(0)
const text = await page.evaluate(() => document.querySelector('[data-testid="view-root"]')?.textContent ?? '')
expect(text.toLowerCase()).toContain('shell')
})
})
49 changes: 49 additions & 0 deletions e2e/timeline-toolbar-overflow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { MAIN_ENTRY, REPO_ROOT, openTestProject, openView } from './helpers'

// docs/DESIGN-core-and-capture.md §6: the eight flat toolbar toggles grouped by
// effect. The low-frequency view/audit controls (session dividers, timezone,
// auditor view) moved behind one "More" control so the row is scannable instead
// of a flat wall of chips. This checks they are reachable there, not lost.

let app: ElectronApplication
let page: Page

test.describe.serial('timeline toolbar overflow', () => {
test.beforeAll(async () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'redlog-toolbar-'))
app = await electron.launch({ args: [MAIN_ENTRY], cwd: REPO_ROOT,
env: { ...process.env, NODE_ENV: 'test', HOME: tmpHome, USERPROFILE: tmpHome, REDLOG_E2E: '1' } })
page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded')
await page.evaluate(() => localStorage.setItem('redlog-locale', 'en'))
await openTestProject(page, 'toolbar-overflow')
await openView(page, 'timeline')
})
test.afterAll(async () => { await app?.close() })

test('the rare view controls are not in the flat row', async () => {
// The timezone select and auditor chip should NOT be visible until the
// overflow is opened — that is the whole point of moving them.
await expect(page.locator('[data-testid="timeline-tz-select"]')).toHaveCount(0)
await expect(page.locator('[data-testid="timeline-more-menu"]')).toBeVisible()
})

test('the overflow reveals them and they still work', async () => {
await page.locator('[data-testid="timeline-more-menu"]').click()
const tz = page.locator('[data-testid="timeline-tz-select"]')
await expect(tz).toBeVisible()
// Toggling the timezone through the menu persists to localStorage.
await tz.selectOption('utc')
const stored = await page.evaluate(() => localStorage.getItem('redlog-timeline-tz'))
expect(stored).toContain('utc')
})

test('the menu closes on outside click', async () => {
await page.mouse.click(5, 5)
await expect(page.locator('[data-testid="timeline-tz-select"]')).toHaveCount(0)
})
})
Loading