Turns the single-serial-worker scheduler into a capacity-aware, multi-lane one. Configured team members become lanes; an issue runs on its assignee's lane (or the earliest-free lane), its duration scaled by that lane's throughput (focusFactor × allocation). Every forecast — Focus cone, Runway, milestone drill-in — is now capacity-aware. core (@commitea/core): - capacity/capacity-v0: CapacityMember + capacityPerWorkday + parseCapacityConfig (clamps, drops invalid; degrades to []). - scheduler/scheduler-capacity-v0: scheduleWithCapacity reuses the v0 topo order + critical path, re-lays work across lanes (layoutOnLanes, resolveLanes, makespan). Empty workers → the single serial plan verbatim. - forecast() gains options.workers: each MC trial lays sampled durations across the lanes and takes the makespan; serial path unchanged. SchedulableIssue gains assignee; ScheduledItem gains worker. - 11 new tests (parse/clamp, parallelism halves makespan, speed scaling, assignee routing, cross-lane deps, forecast makespan shrinks with lanes). app: - pm-state capacity/members.json read (readCapacity + pmstate:capacity bridge); useCapacity hook → workers; forecastBacklog/runwayView/milestoneView pass workers. - Runway Capacity card shows the real config (person · focus · alloc · pd/day). Config lives in pm-state (D4); seeded christian(0.8)/stephen(0.6×0.5). Degrades to the fixture/serial when absent. Verified: 128 core tests green, desktop typecheck clean, 14 fixture e2e green. Live: the capacity card is real, and the P2 forecast shifts 32d→37d — honest, since real focus factors (<1) replace the v0 focus-1.0 assumption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
5.1 KiB
TypeScript
81 lines
5.1 KiB
TypeScript
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { _electron as electron, expect, test } from '@playwright/test'
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
const MAIN = join(here, '..', 'out', 'main', 'index.js')
|
|
|
|
// Opt-in only (GITEA_LIVE=1). Launches WITHOUT COMMITEA_E2E so the main process
|
|
// reconciles the real repo via .env.local, and asserts real data renders.
|
|
test.describe('live backlog', () => {
|
|
test('The pot + Focus render real gitea data', async () => {
|
|
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
|
|
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
|
|
const win = await app.firstWindow()
|
|
await win.waitForLoadState('domcontentloaded')
|
|
const rail = win.getByRole('navigation', { name: 'Primary' })
|
|
|
|
// The pot — waiting here also lets the reconcile (issues + deps + timelines) complete
|
|
await rail.getByRole('button', { name: 'The pot' }).click()
|
|
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 })
|
|
// Lifecycle inference (#5): columns come from the real event stream — merged
|
|
// work lands in Done, so that column is non-empty (proves timelines drove it,
|
|
// not the three-column fallback which would still show closed issues in Done).
|
|
await expect(win.getByText('Done', { exact: true })).toBeVisible()
|
|
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' })
|
|
|
|
// Back to Focus — the deterministic scheduler's real Now/Next/Later. These
|
|
// rationale phrases are emitted only by the scheduler, never by the demo fixture.
|
|
await rail.getByRole('button', { name: 'Morning service' }).click()
|
|
await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible()
|
|
// Real Monte Carlo cone — this headline is emitted only for real forecasts.
|
|
await expect(win.getByText(/80% of the open backlog lands by/)).toBeVisible()
|
|
await expect(win.getByText(/Cold-start priors/)).toBeVisible()
|
|
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' })
|
|
|
|
// Runway → calibration surface, fitted from real closed-issue actuals (#1).
|
|
// With <20 estimated closes the repo is honestly cold-start; the note proves
|
|
// the fit ran on real data, not the fixture's "calibrated on 27".
|
|
await rail.getByRole('button', { name: 'Runway' }).click()
|
|
await expect(
|
|
win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/),
|
|
).toBeVisible()
|
|
// Real per-milestone forecasts — these milestone names come from gitea, not the
|
|
// fixture (which lists Beta / Pilot-ready / v1.0).
|
|
await expect(win.getByText(/P2 — Scheduler/)).toBeVisible()
|
|
// Real capacity config from pm-state (christian/stephen), not the fixture (Stephen/Ana K.)
|
|
await expect(win.getByText('christian', { exact: true })).toBeVisible()
|
|
await expect(win.getByText(/pd\/day/).first()).toBeVisible()
|
|
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-runway.png'), fullPage: true, animations: 'disabled' })
|
|
|
|
// Milestone drill-in — clicking a real milestone opens its real detail
|
|
await win.getByText(/P2 — Scheduler/).click()
|
|
await expect(win.getByRole('heading', { name: 'P2 — Scheduler + Monte Carlo' })).toBeVisible()
|
|
await expect(win.getByText(/\d+ issues · est \d+d/)).toBeVisible()
|
|
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-milestone.png'), fullPage: true, animations: 'disabled' })
|
|
await rail.getByRole('button', { name: 'Runway' }).click()
|
|
|
|
await win.getByRole('button', { name: 'Full report' }).click()
|
|
await expect(win.getByText(/cold-start · \d+\/20|curve active · n ≥ 20/)).toBeVisible()
|
|
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-calibration.png'), fullPage: true, animations: 'disabled' })
|
|
|
|
// Write path (apply_changes): open the top scheduled issue, propose an
|
|
// estimate change, and confirm the propose-approve diff renders — then CANCEL
|
|
// so the live run never mutates the real repo (the PUT is unit-tested; a
|
|
// one-off change→revert verified it against gitea manually).
|
|
await rail.getByRole('button', { name: 'Morning service' }).click()
|
|
await win.getByRole('main').getByRole('link').first().click()
|
|
await expect(win.getByText(/· stephen\/commitea/)).toBeVisible()
|
|
await win.getByRole('button', { name: 'Adjust' }).click()
|
|
await expect(win.getByText('Adjust estimate & priority')).toBeVisible()
|
|
await win.getByRole('combobox').first().selectOption('est/8d')
|
|
await expect(win.getByText('Proposed label change')).toBeVisible()
|
|
await expect(win.getByText(/est\/8d/).last()).toBeVisible()
|
|
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-apply-change.png'), fullPage: true, animations: 'disabled' })
|
|
await win.getByRole('button', { name: 'Cancel' }).click() // no mutation
|
|
|
|
await app.close()
|
|
})
|
|
})
|