|
| 1 | +import { test, expect, type Page } from '@playwright/test' |
| 2 | +import { mkdirSync } from 'node:fs' |
| 3 | +import { join } from 'node:path' |
| 4 | +import { attachConsoleErrors, forceLocale, gotoApp } from './helpers' |
| 5 | + |
| 6 | +/* |
| 7 | + * GDK-1709 / GDK-1711 — the sprint strip and the carry-over mark. |
| 8 | + * |
| 9 | + * The strip is the active sprint given a line of its own between the toolbar |
| 10 | + * and the columns: name, goal, dates, days left, and a two-segment bar in |
| 11 | + * the board's own category colours. It exists only while the board is scoped |
| 12 | + * to exactly one active sprint, which on the demo fixture is Sprint 42 |
| 13 | + * (2026-09-02 → 2026-09-16, 20 issues: 6 done, 4 in progress, 10 to do — |
| 14 | + * measured against examples/demo.db, not against the pool this tab loaded). |
| 15 | + * |
| 16 | + * The counts come from GET /api/v1/issues/sprints/, so they describe the whole |
| 17 | + * sprint. The board beside them is the *open* pool narrowed by the scope, and |
| 18 | + * the two numbers are allowed to differ — that difference is the reason the |
| 19 | + * server does the counting. |
| 20 | + * |
| 21 | + * The carry-over mark reads issues_raw.carryover_count (13 of Sprint 42's |
| 22 | + * issues carry one on the fixture). Null, never 0, on an origin with no |
| 23 | + * changelog, so an unmarked card is not a claim. |
| 24 | + */ |
| 25 | + |
| 26 | +/* Captures happen only when a round asks for them by naming a directory |
| 27 | + * (GDK-1570, e2e/capture-guard.unit.ts): CI runs this file for its |
| 28 | + * assertions, and nobody consumes PNGs there. */ |
| 29 | +const SHOTS = process.env.SPRINT_SHOT_DIR ?? '' |
| 30 | + |
| 31 | +/* |
| 32 | + * Board, scoped to the active sprint — the one state the strip renders in. |
| 33 | + * |
| 34 | + * By address, not by clicking: `gotoApp` waits on the English pool count |
| 35 | + * ("534 issues"), which never appears on a ko or ja boot, and this helper is |
| 36 | + * what the three-locale captures below come through. Same hash the CLI's |
| 37 | + * `views open --jql 'sprint in openSprints()'` writes (internal/jql/hash.go). |
| 38 | + */ |
| 39 | +async function openScopedBoard(page: Page): Promise<void> { |
| 40 | + await page.goto('/#/?ly=board&sst=active') |
| 41 | + await expect(page.getByTestId('board')).toBeVisible({ timeout: 30_000 }) |
| 42 | + await expect(page).toHaveURL(/sst=active/) |
| 43 | +} |
| 44 | + |
| 45 | +test.describe('GDK-1709 sprint strip', () => { |
| 46 | + test('appears only under the active scope and reports the whole sprint', async ({ page }) => { |
| 47 | + const errors = attachConsoleErrors(page) |
| 48 | + await forceLocale(page, 'en') |
| 49 | + await gotoApp(page) |
| 50 | + await page.getByTestId('view-settings').click() |
| 51 | + await page.getByTestId('layout-board').click() |
| 52 | + await expect(page.getByTestId('board')).toBeVisible() |
| 53 | + |
| 54 | + // "All" is not a sprint: nothing to describe, nothing drawn. |
| 55 | + await expect(page.getByTestId('sprint-strip')).toHaveCount(0) |
| 56 | + |
| 57 | + await page.getByTestId('sprint-scope-active').click() |
| 58 | + const strip = page.getByTestId('sprint-strip') |
| 59 | + await expect(strip).toBeVisible() |
| 60 | + await expect(page.getByTestId('sprint-strip-name')).toHaveText('Sprint 42') |
| 61 | + |
| 62 | + // The whole sprint, from the server — 20 issues, 6 of them done. The |
| 63 | + // board's own card count is the open pool under the same scope and is |
| 64 | + // deliberately a different number. |
| 65 | + await expect(page.getByTestId('sprint-strip-count')).toHaveText('6 / 20 · 30%') |
| 66 | + |
| 67 | + // The bar's two filled segments, in the order work moves, widths from |
| 68 | + // the same counts as the text beside them. To do is the bare track (the |
| 69 | + // vision pass read a painted third segment as a blue ribbon before it |
| 70 | + // read the bar as 30% done), so a 'new' segment is a regression here. |
| 71 | + const bar = page.getByTestId('sprint-strip-bar') |
| 72 | + await expect(bar).toHaveAttribute('title', 'done 6 · in progress 4 · to do 10') |
| 73 | + await expect(bar.locator('[data-segment="new"]')).toHaveCount(0) |
| 74 | + for (const [seg, n] of [ |
| 75 | + ['done', 6], |
| 76 | + ['inprogress', 4], |
| 77 | + ] as const) { |
| 78 | + const el = bar.locator(`[data-segment="${seg}"]`) |
| 79 | + await expect(el).toBeVisible() |
| 80 | + const width = await el.evaluate((e) => (e as HTMLElement).style.width) |
| 81 | + expect(width).toBe(`${(n / 20) * 100}%`) |
| 82 | + } |
| 83 | + |
| 84 | + // The dates line carries both bounds and a days phrase, never a "D-n". |
| 85 | + const dates = await page.getByTestId('sprint-strip-dates').textContent() |
| 86 | + expect(dates).toMatch(/2026/) |
| 87 | + expect(dates).not.toMatch(/D-/) |
| 88 | + |
| 89 | + // Back to All and the strip goes away rather than describing a board |
| 90 | + // that is no longer one sprint. |
| 91 | + await page.getByTestId('sprint-scope-all').click() |
| 92 | + await expect(page.getByTestId('sprint-strip')).toHaveCount(0) |
| 93 | + |
| 94 | + expect(errors).toEqual([]) |
| 95 | + }) |
| 96 | + |
| 97 | + test('the bar wears the status-category tokens, not a palette of its own', async ({ page }) => { |
| 98 | + await forceLocale(page, 'en') |
| 99 | + await openScopedBoard(page) |
| 100 | + await expect(page.getByTestId('sprint-strip')).toBeVisible() |
| 101 | + |
| 102 | + // The board's own column headers draw no category dots when the board is |
| 103 | + // already grouped by category (BoardView passes showCategoryCounts=false), |
| 104 | + // so there is no sibling element to read the colour off. What can be |
| 105 | + // asserted — and what the contract actually is — is that each segment |
| 106 | + // resolves to the same paint as the status-category token every other |
| 107 | + // category mark on the app uses. A second palette invented for this bar |
| 108 | + // is what this fails on; it does not prove the two elements share a |
| 109 | + // function, which the vision pass checks by eye. |
| 110 | + const tokens = await page.evaluate(() => { |
| 111 | + const read = (v: string) => { |
| 112 | + const probe = document.createElement('span') |
| 113 | + probe.style.background = `var(${v})` |
| 114 | + document.body.append(probe) |
| 115 | + const c = getComputedStyle(probe).backgroundColor |
| 116 | + probe.remove() |
| 117 | + return c |
| 118 | + } |
| 119 | + return { |
| 120 | + done: read('--color-status-done'), |
| 121 | + inprogress: read('--color-status-inprogress'), |
| 122 | + } as Record<string, string> |
| 123 | + }) |
| 124 | + const segs = await page |
| 125 | + .getByTestId('sprint-strip-bar') |
| 126 | + .locator('[data-segment]') |
| 127 | + .evaluateAll((els) => |
| 128 | + Object.fromEntries( |
| 129 | + els.map((e) => [ |
| 130 | + (e as HTMLElement).dataset.segment ?? '', |
| 131 | + getComputedStyle(e).backgroundColor, |
| 132 | + ]), |
| 133 | + ), |
| 134 | + ) |
| 135 | + expect(Object.keys(segs).sort()).toEqual(['done', 'inprogress']) |
| 136 | + for (const cat of Object.keys(segs)) { |
| 137 | + expect(segs[cat], `${cat} must be the status-category token's own paint`).toBe(tokens[cat]) |
| 138 | + } |
| 139 | + }) |
| 140 | + |
| 141 | + test('GDK-1711 the carry-over mark is on the cards that were carried', async ({ page }) => { |
| 142 | + await forceLocale(page, 'en') |
| 143 | + await openScopedBoard(page) |
| 144 | + const marks = page.getByTestId('board-card-carryover') |
| 145 | + // 13 of Sprint 42's 20 issues carry one; the board shows the open subset, |
| 146 | + // so this is "some, not all, and never on every card". |
| 147 | + const n = await marks.count() |
| 148 | + expect(n).toBeGreaterThan(0) |
| 149 | + expect(n).toBeLessThan(await page.getByTestId('board-card').count()) |
| 150 | + await expect(marks.first()).toHaveAttribute('title', /Carried over from/) |
| 151 | + }) |
| 152 | +}) |
| 153 | + |
| 154 | +/* |
| 155 | + * Captures. Six frames (three locales × two themes) plus a card close-up, for |
| 156 | + * the visual contract: does the strip read as a line of the board's chrome, |
| 157 | + * do ko and ja fit without truncation, does the bar hold contrast in dark. |
| 158 | + */ |
| 159 | +test.describe('sprint strip captures', () => { |
| 160 | + test('capture the list row mark', async ({ page }) => { |
| 161 | + test.skip(!process.env.SPRINT_SHOT_DIR, 'capture-only; set SPRINT_SHOT_DIR to shoot') |
| 162 | + mkdirSync(SHOTS, { recursive: true }) |
| 163 | + // The row is the mark's other surface, and it folds where `assignee` and |
| 164 | + // `updated` do — this frame is the wide row, where it paints. |
| 165 | + await page.setViewportSize({ width: 1440, height: 900 }) |
| 166 | + await forceLocale(page, 'en') |
| 167 | + await page.goto('/#/?sst=active') |
| 168 | + await expect(page.getByTestId('issue-row-carryover').first()).toBeVisible({ timeout: 30_000 }) |
| 169 | + await page.screenshot({ path: join(SHOTS, 'list-carryover.png') }) |
| 170 | + }) |
| 171 | + |
| 172 | + for (const locale of ['en', 'ko', 'ja'] as const) { |
| 173 | + for (const scheme of ['light', 'dark'] as const) { |
| 174 | + test(`capture ${locale} ${scheme}`, async ({ page }) => { |
| 175 | + test.skip(!process.env.SPRINT_SHOT_DIR, 'capture-only; set SPRINT_SHOT_DIR to shoot') |
| 176 | + mkdirSync(SHOTS, { recursive: true }) |
| 177 | + await page.emulateMedia({ colorScheme: scheme }) |
| 178 | + await forceLocale(page, locale) |
| 179 | + await openScopedBoard(page) |
| 180 | + await expect(page.getByTestId('sprint-strip')).toBeVisible() |
| 181 | + await page.screenshot({ path: join(SHOTS, `board-${locale}-${scheme}.png`) }) |
| 182 | + await page |
| 183 | + .getByTestId('sprint-strip') |
| 184 | + .screenshot({ path: join(SHOTS, `strip-${locale}-${scheme}.png`) }) |
| 185 | + if (locale === 'en' && scheme === 'light') { |
| 186 | + const card = page |
| 187 | + .getByTestId('board-card') |
| 188 | + .filter({ has: page.getByTestId('board-card-carryover') }) |
| 189 | + .first() |
| 190 | + await card.screenshot({ path: join(SHOTS, 'card-carryover.png') }) |
| 191 | + } |
| 192 | + }) |
| 193 | + } |
| 194 | + } |
| 195 | +}) |
0 commit comments