diff --git a/console/tests/time.test.cjs b/console/tests/time.test.cjs index b41ec6a8..772740aa 100644 --- a/console/tests/time.test.cjs +++ b/console/tests/time.test.cjs @@ -26,7 +26,7 @@ before(async () => { for (let number = 1; number <= 7; number++) { const live = number === 1; const record = { - version: 1, kind: 'Run', name: `run-${number}`, repository: 'acme/widgets', hostname: 'fixture', + version: 1, kind: number === 2 ? 'Spec run' : 'Run', name: `run-${number}`, repository: 'acme/widgets', hostname: 'fixture', issue: { number, url: `https://github.com/acme/widgets/issues/${number}`, title: `Run ${number} ` }, process_id: live ? process.pid : 0, process_start: live ? processStart : 'gone', harness: 'codex', started_at: time(number === 7 ? 1500 : 180 - number * 10), @@ -38,9 +38,21 @@ before(async () => { ], ...(number === 6 ? { parent_record: join(folder, '2-fixture.command.json') } : {}) }; + if (live) { + const dispatched = { ...record }; + record.kind = 'Pickup run'; + record.name = 'pickup-fixture'; + record.stages = [{ name: 'dispatched Run', began_at: time(170), session_log: null }]; + record.dispatched = [dispatched]; + } await writeFile(join(folder, `${number}-fixture.command.json`), JSON.stringify(record)); await writeFile(join(folder, `${number}-fixture.log`), `Command ${number}\n`); } + const security = JSON.parse(await readFile(join(folder, '3-fixture.command.json'), 'utf8')); + security.kind = 'Security run'; + security.name = 'security-fixture'; + security.issue = null; + await writeFile(join(folder, '10-fixture.command.json'), JSON.stringify(security)); // A finished Run in an alphabetically earlier repository must not precede // this repository's live Run, or reorder the finished columns by repository. const otherFolder = join(home, '.thirdshift/logs/aaa/first/commands/issue'); @@ -75,7 +87,7 @@ test('Lanes scales every window, nests child Runs and fits 390 px without CSP vi for (const hours of [3.5, 12, 24]) { await page.goto(`${origin}/?page=lanes&hours=${hours}`); assert.equal(await page.locator('[name="hours"]').inputValue(), String(hours)); - assert.equal(await page.locator('.lane').count(), 6); + assert.equal(await page.locator('.lane').count(), 7); assert.equal(await page.locator('.lane').filter({ hasText: 'acme/widgets#2' }).locator('.time-run').count(), 2); const complete = page.locator('.time-run').filter({ hasText: 'acme/widgets#3' }); const track = await complete.locator('.timeline').boundingBox(); @@ -98,21 +110,48 @@ test('Lanes scales every window, nests child Runs and fits 390 px without CSP vi assert.deepEqual(errors, []); }); +test('time headings distinguish a live Pickup run and its Run and name kinds once at 390 px', { skip: process.platform !== 'linux' }, async t => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + for (const view of ['lanes', 'stages']) { + await page.goto(`${origin}/?page=${view}`); + const headings = view === 'lanes' ? page.locator('.time-run h3') : page.locator('thead .command'); + for (const heading of ['Pickup run · acme/widgets#1', 'Run · acme/widgets#1', 'Spec run · acme/widgets#2', 'Run · acme/widgets#6', 'Security run on acme/widgets']) { + assert.equal(await headings.getByRole('link', { name: heading, exact: true }).count(), 1); + if (view === 'stages') { + assert.ok((await page.locator('.cell-run').allTextContents()).includes(`${heading}: `)); + } + } + for (const heading of ['Pickup run · acme/widgets#1', 'Run · acme/widgets#1']) { + const link = headings.getByRole('link', { name: heading, exact: true }); + assert.match(await link.locator('..').textContent(), /live/); + await link.click(); + await page.locator('#peek-content h2').waitFor(); + const details = await page.locator('#peek-content').textContent(); + assert.ok(details.includes(heading.startsWith('Pickup') ? 'pickup-fixture' : 'run-1')); + await page.keyboard.press('Escape'); + } + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + } +}); + test('Stages pins live Runs, matches CLI order and opens the selected child Run at 390 px', { skip: process.platform !== 'linux' }, async t => { const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); t.after(() => context.close()); const page = await context.newPage(); await page.goto(`${origin}/?page=stages`); const status = JSON.parse(execFileSync(binary, ['status', '--all', '--json'], { env: { ...process.env, HOME: home } })); - const runs = status.commands.flatMap(command => [command, ...(command.children ?? [])]); + const flatten = command => [command, ...(command.dispatched ?? []).flatMap(flatten), ...(command.children ?? []).flatMap(flatten)]; + const runs = status.commands.flatMap(flatten); runs.sort((a, b) => (a.state !== 'live') - (b.state !== 'live') || Date.parse(b.ended_at ?? b.started_at) - Date.parse(a.ended_at ?? a.started_at)); - assert.deepEqual(await page.locator('thead .peek-link').allTextContents(), runs.map(run => `${run.repository}#${run.issue.number}`)); - assert.equal(await page.locator('thead .command').first().getAttribute('data-command'), runs[0].record_path ?? `${join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json')}::run-1`); + assert.deepEqual(await page.locator('thead .peek-link').allTextContents(), runs.map(run => run.issue ? `${run.kind} · ${run.repository}#${run.issue.number}` : `${run.kind} on ${run.repository}`)); + assert.equal(await page.locator('thead .command').first().getAttribute('data-command'), runs[0].record_path ?? `${join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json')}::pickup-fixture`); assert.match(await page.locator('.stage-grid').first().textContent(), /Running ·/); assert.match(await page.locator('.stage-grid').first().textContent(), /Usual range: 10–10 min/); const historical = page.locator('.stage-grid').filter({ has: page.locator('thead .peek-link', { hasText: 'acme/widgets#9' }) }); const headers = await historical.locator('thead .peek-link').allTextContents(); - const historicalCell = historical.locator('tbody tr').first().locator('td').nth(headers.indexOf('acme/widgets#9')); + const historicalCell = historical.locator('tbody tr').first().locator('td').nth(headers.indexOf('Run · acme/widgets#9')); assert.match(await historicalCell.textContent(), /Unknown · no stage times retained/); assert.doesNotMatch(await historicalCell.textContent(), /Not reached/); const child = page.locator('thead .peek-link').filter({ hasText: 'acme/widgets#6' }); diff --git a/src/logs/status/time.rs b/src/logs/status/time.rs index fb9949b2..5eecfad9 100644 --- a/src/logs/status/time.rs +++ b/src/logs/status/time.rs @@ -5,7 +5,7 @@ use anyhow::Result; use chrono::{DateTime, Local}; use crate::config::UserConfig; -use crate::logs::command_record::StageName; +use crate::logs::command_record::{Issue, StageName}; use crate::logs::reading::{Command, Entry, Observation, Snapshot}; pub(crate) struct Views { @@ -64,6 +64,13 @@ fn timestamp(value: &str) -> Option { .map(|time| time.timestamp()) } +fn subject(kind: &str, repository: &str, issue: Option<&Issue>) -> String { + issue.map_or_else( + || format!("{kind} on {repository}"), + |issue| format!("{kind} · {repository}#{}", issue.number), + ) +} + fn run(command: &Command, now: DateTime, cutoff: i64) -> Run { let record = &command.record; let selector = Observation::Record(command).selector().unwrap_or_default(); @@ -127,10 +134,7 @@ fn run(command: &Command, now: DateTime, cutoff: i64) -> Run { }) .collect(); Run { - subject: record.issue.as_ref().map_or_else( - || format!("{} on {}", record.kind, record.repository), - |issue| format!("{}#{}", record.repository, issue.number), - ), + subject: subject(&record.kind, &record.repository, record.issue.as_ref()), title: record .issue .as_ref() @@ -172,10 +176,7 @@ pub(crate) fn load(config: &UserConfig, hours: f64) -> Result { Entry::History(command) => { let selector = Observation::History(command).selector().unwrap_or_default(); runs.push(Run { - subject: command.issue.as_ref().map_or_else( - || format!("{} on {}", command.kind, command.repository), - |issue| format!("{}#{}", command.repository, issue.number), - ), + subject: subject(&command.kind, &command.repository, command.issue.as_ref()), title: String::new(), state: command .outcome diff --git a/tests/console.rs b/tests/console.rs index 4e989a4d..f4132604 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -445,6 +445,52 @@ fn waterfall_nests_ticket_runs_and_matches_codex_items_once_in_their_scope() { ); } +#[test] +fn time_pages_name_the_command_kind_without_repeating_it_for_commands_without_issues() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let mut pickup = record(home.path(), 7, Some("merged"), 0); + pickup["kind"] = json!("Pickup run"); + pickup["name"] = json!("pickup-fixture"); + pickup["stages"][0]["name"] = json!("dispatched Run"); + let mut dispatched = pickup.clone(); + dispatched["kind"] = json!("Run"); + dispatched["name"] = json!("acme/widgets#7"); + dispatched["stages"][0]["name"] = json!("implement"); + pickup["dispatched"] = json!([dispatched]); + write_record(home.path(), 7, &pickup); + let mut security = record(home.path(), 8, Some("findings recorded"), 0); + security["kind"] = json!("Security run"); + security["issue"] = Value::Null; + write_record(home.path(), 8, &security); + let now = chrono::Local::now(); + let time = now.format("%Y-%m-%d %H:%M:%S"); + let stamp = now.format("%Y%m%dT%H%M%S%z"); + fs::write( + home.path().join(".thirdshift/logs/acme/widgets/activity.log"), + format!( + "{time} Spec run #9 started: commands/issue/9-{stamp}.log\n{time} Spec run #9 ended: merged\n" + ), + ) + .unwrap(); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + for page in ["/lanes", "/stages"] { + let html = console.get(page).text().unwrap(); + for heading in [ + "Pickup run · acme/widgets#7", + "Run · acme/widgets#7", + "Security run on acme/widgets", + "Spec run · acme/widgets#9", + ] { + assert!(html.contains(&format!(">{heading}")), "{page}: {html}"); + if page == "/stages" { + assert!(html.contains(&format!("class=\"cell-run\">{heading}: "))); + } + } + assert!(!html.contains("Security run · Security run")); + } +} + #[test] fn time_pages_pair_with_all_status_stages_and_repository_usual_ranges() { let home = tempfile::tempdir().unwrap();