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();