app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
Publish 0.57.1
8 files changed
+1378−505
README.md+3@@ -404,8 +404,11 @@ A browser suite drives the real app in headless Chrome against a seeded 20-secti cd test && npm install # puppeteer-core, once; Chrome from ~/.cache/puppeteer node test/ui_test.mjs # exits non-zero on any failure node test/ui_test.mjs --keep --plan path/to/plan.json # also a real plan (copied first); keep screenshots in test/out/+node test/ui_test.mjs --only "editor: item,text size" # a subset of flows, for a fast inner loop ``` +A full run takes about 12 minutes (14 with `--plan`). Since 0.57.1 the suite also opens every editor and menu, sweeps the header at 54 widths in all three lenses, fails on any failed request to the app's own server, and fails when a control marked safe was never clicked. Controls with no test are marked `untested` with a reason and counted in the summary, so the gap is stated and not hidden.+ ## Collaboration Each team member runs their own server instance with the same data file. Use Export (JSON) to share the current state and Import (JSON) to load a colleague's version. The data file and sidecar state file can also be committed to a shared git repo for async collaboration.
SKILL.md+1−1@@ -218,7 +218,7 @@ rather than a category. - **New** button (or Shift+N): create a task or milestone. After a successful create, the next New panel prefills EVERY field from the one you just made (type, phase, owner, status, start, deadline or milestone date, notes, provenance label). Only the name starts empty, so a run of tasks for the same person in the same lane in the same week is names and nothing else. A provenance left at its default is not remembered as a value, so it keeps following the signed-in identity. It is in-memory for the page session only: a refresh clears it, and it is never written to localStorage, the preferences or the sidecar, so it is never carried to anyone else. Only a create that produced a row updates it: Cancel, Escape and a validation-rejected create all leave it untouched. Created rows live in the sidecar as `added: [...]`, keyed in a `#new-` namespace that cannot collide with the data file's `#row<n>`, and are merged into every export - **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type. **It survives a refresh in the live app AND in an exported HTML snapshot** (fixed in 0.57.0). Text size is a READER preference, so it is the one pref a snapshot reads back from and writes to the reader's storage; everything else in a snapshot still opens at the author's view, and a snapshot now writes only the size, so it can no longer overwrite the grain, lens or collapse set a reader has stored on the same origin. Open tabs of the app follow a size change made in another tab live. **Every pref is saved merge-on-write**: a save writes only the keys this tab changed since it last read or wrote them, into a fresh read of storage, so a second tab saving its theme can no longer reopen the lanes you collapsed in the first, or reset your text size-- **Project Duration and Time Remaining** in the header, stacked, beside the lens switch (0.57.0, replacing the old "25 weeks" axis-cell count, which changed with the grain and counted the axis padding). Both come from the plan's own dates and never from the grain: Duration runs from the earliest task start or milestone date to the latest task end or milestone date, both days counted, and Remaining runs from today to that last date, today counted. **Cancelled rows are left out**, as Auto-schedule, the critical path and the hours totals leave them out, and the tooltip says how many were; if every dated row is cancelled both read `none` and the tooltip says why. (The start button still counts cancelled rows, because it moves the whole plan. The Analytics "calendar span" card still includes them; it was not changed.) Format: days under two weeks, `16w 4d` style under half a year, then months and years to one decimal. The tooltips give the exact calendar and working-day counts and both dates. Remaining reads `ended` once the last date is past; before the first date it still counts from today, so it can be longer than Duration, and a small `not started` note sits on the readout itself to say why. Both read `none` for a plan with no dated rows. They update on every edit that can move a date (drag, editor, table edit, start shift, auto-schedule, create, delete, paste, undo, redo) and at midnight on a tab left open. The header FITS ITSELF to its content rather than to pixel breakpoints (`fitHeader`): it measures the row and, only while the content is wider than the window, sheds in order the version, the button words, the long labels (Duration, Remaining), and finally wraps onto a second row. The readouts never shrink, so they are whole at every width; the suite sweeps 360 to 2000px with and without an appName at 100% and 150% text to hold that+- **Project Duration and Time Remaining** in the header, stacked, beside the lens switch (0.57.0, replacing the old "25 weeks" axis-cell count, which changed with the grain and counted the axis padding). Both come from the plan's own dates and never from the grain: Duration runs from the earliest task start or milestone date to the latest task end or milestone date, both days counted, and Remaining runs from today to that last date, today counted. **Cancelled rows are left out**, as Auto-schedule, the critical path and the hours totals leave them out, and the tooltip says how many were; if every dated row is cancelled both read `none` and the tooltip says why. (The start button still counts cancelled rows, because it moves the whole plan. The Analytics "calendar span" card still includes them; it was not changed.) Format: days under two weeks, `16w 4d` style under half a year, then months and years to one decimal. The tooltips give the exact calendar and working-day counts and both dates. Remaining reads `ended` once the last date is past; before the first date it still counts from today, so it can be longer than Duration, and a small `not started` note sits on the readout itself to say why. Both read `none` for a plan with no dated rows. They update on every edit that can move a date (drag, editor, table edit, start shift, auto-schedule, create, delete, paste, undo, redo) and at midnight on a tab left open. The header FITS ITSELF to its content rather than to pixel breakpoints (`fitHeader`): it measures the row and, only while the content is wider than the window, sheds in order the version, the button words and the long labels (Duration, Remaining). If that still does not fit, it wraps onto a second row with the long labels back, and with the button words back too whenever they cost no extra row (they stay off on a phone, where they would). The readouts never shrink, so they are whole at every width; the suite sweeps 360 to 2000px with and without an appName at 100% and 150% text to hold that - **Fit** button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only - **Gantt / Table / Analytics toggle** pinned first in the header, left of every conditional control so its position never shifts. Three lenses over the same items: the chart answers "when", the table answers "whose, and by when", and analytics answers "how much, and is any of it in trouble". See The table view and The analytics view below - **Auto-schedule** button, with the plan-level controls next to Fit, in the chart and the table lenses. One press lays the WHOLE plan out under two rules at once: no task with dependencies starts before its latest predecessor's end date, and no task starts or ends on a Saturday, a Sunday or a US federal holiday. The handoff date is a MINIMUM and not an answer: it is BACK TO BACK for a task that was overlapping and is pushed onto it, and back to back is a statement about the CHART, since a bar runs from its start to its end and the end is the boundary it stops at, so a successor starting on that date draws flush against it with no gap and no overlap. **PUSH ONLY, since 0.53.0.** A task that already starts on or after its handoff is LEFT WHERE IT IS, however wide the gap, because a gap in a real plan is usually somebody's lead time: an order placed on the Monday and the parts that arrive on the Thursday keep their three days. Through 0.52.0 the pass went both ways and closed every one of those, and there was no way to keep one. Nothing is ever pulled EARLIER now, so the plan cannot drift backwards over a press, and the pass reaches a fixed point in ONE press: the second moves nothing. Pushes still cascade, each successor measured against where its predecessor LANDED and not against the date it shipped with. **CANCELLED WORK IS NOT IN THE PASS AT ALL,** also since 0.53.0: it is never moved, so its dates freeze exactly as set even if they violate something, and it constrains nobody, so a task whose only predecessors are cancelled behaves like an anchor and one with a mix is pushed by its LIVE predecessors alone. The toast counts those rows as their own third number ("5 tasks moved, 15 unchanged, 1 cancelled excluded") rather than folding them into the count of work that was already aligned. A task with no dependencies is an ANCHOR and keeps its start (shifted LATER off a weekend or a holiday if it was on one), because that is the one thing in a plan this cannot derive. Duration is preserved in WORKING days, not calendar days, so a five-day task laid across a weekend, or across Labor Day, still takes five working days: a predecessor ending on the Friday hands off on the Friday and the weekend falls inside the successor's own span, never between the two. Milestones take the same rule with no duration: off a weekend or a holiday to the next working day, and on the day their last dependency ends. The graph crosses sections freely and so does the schedule; a section's displayed range just follows its rows. A dependency CYCLE is left exactly as it is, along with anything downstream of it, and the toast names the rows: there is no honest answer for a row that waits on itself. The whole pass is ONE undo entry and ONE changelog event (`action: "auto-schedule"`, with a count and the names of what moved), and a press that moves nothing writes nothing. Deterministic. Disabled, with a tooltip that says why, when the plan has no dependencies
docs/BUILD-NOTES.md+43@@ -3,6 +3,49 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-09-29 (0.57.1, second review: a failed save is retried, a wrapped header keeps its words)++Three narrow reviewers ran in parallel against 0.57.0 after it was published: the header+fit, the preferences and the duration clock, and the test suite itself. The app held up.+Every width from 320 to 2000px in all three lenses fitted, nothing oscillated, the clock+was right in two timezones and for every spelling of cancelled, and the title, subtitle+and appName were escaped wherever they are painted. What they did find:++* **A save the browser refused was marked as saved.** `savePrefs()` moved its baseline+ before `setItem`, so a write that threw (quota, blocked storage) was never tried again+ and the change was lost on the next refresh. The baseline now moves only after the+ write lands. Reproduced first: A+ with a failing write, then a theme change, then a+ reload read 100%. Now it reads 110%.+* **A cleared or unreadable store got one key back.** With nothing usable stored there+ is nothing newer to protect, so the tab writes everything it holds and a refresh opens+ the way the tab looked.+* **Invalid stored values lingered.** The clamps corrected them in memory on every load+ and the merge never wrote the correction. `healStoredPrefs()` rewrites only a value+ that is invalid, once, at startup. A valid value is left alone, and so is the critical+ mode a dependency-free plan drops in memory.+* **A label-width drag across the end of startup** could look already saved. The+ mouseup forgets the baseline for that key before it saves.+* **The wrapped header hid the button words for nothing.** A wrapping row always fits+ by width, so width cannot choose between words and icons. Height does: the words come+ back when they cost no extra row (760 to about 1360px on the instaPCB plan) and stay+ off when they would (a phone).++Two undo bugs inherited from 0.56.0, both caught by the hardened suite:++* **The full editor's Apply** recorded the field edits and then moved the row to its new+ section without recording the move, so Ctrl+Z restored every field except the section.+ The two halves are one undo entry now.+* **A batch Apply, and a drag of several selected bars,** pushed one undo entry per row,+ so one Ctrl+Z put a single row back. The old test passed by accident: it read the one+ row the undo happened to restore. One gesture is one entry now, and the test reads+ every row.++Left as it is, on purpose: the header can change state once when the web fonts arrive,+because the first fit measures the fallback font. It settles in one step and the fit+re-runs on `fonts.ready`.++The test suite was hardened in the same pass; see the Tests section of the README.+ ## Build notes, 2026-09-29 (0.57.0, a quieter header, collapse all, a legend that wraps, a text size that sticks) Six things Drew asked for, from two screenshots of the instaPCB launch plan (20 sections,
docs/build-2026-09-29.mdadded+127@@ -0,0 +1,127 @@+# three-musketeers build: adom-gantt 0.57.0 and 0.57.1, 2026-09-29++## What was built++adom-gantt 0.57.1, in `gantt.html` (`server.js` is unchanged), plus the package's first+browser test suite, `test/ui_test.mjs`.++* **Collapse all / Expand all.** One button at the start of the legend row. It persists+ across a refresh and the Table lens agrees with the chart.+* **Text size survives a refresh.** It did not, for two reasons: an exported snapshot+ discarded every saved preference, and in the live app a second open tab wrote its old+ size back over the new one. Both are fixed. Preferences are now written key by key, so+ no tab can overwrite what another tab changed.+* **Quieter header.** The bold repeat of the title and the dimmed description are gone.+ The brand block shows `appName`, or the project title when there is none.+* **Exact start date** at every cell size.+* **Project Duration and Time Remaining** replace the axis cell count ("25 weeks"). On the+ instaPCB plan, on 2026-09-29, they read `16w 4d` and `15w 3d`. They ignore the cell+ size, update on every kind of edit, and leave cancelled rows out.+* **The legend wraps**, so all 20 sections are visible and clickable.+* **The header fits by measuring**, not by pixel breakpoints, so the readouts are never+ clipped and no control runs off screen from 320 to 2000px.++Independently verified state: a verifier drove all six behaviours in real Chrome and+passed them with no page errors. The orchestrator re-ran the suite outside the builder:+see Convergence for the final counts.++## Decisions for you++1. **Re-export the instaPCB wiki snapshot.** `drew2/instapcb-launch` carries+ `gantt/index.html`, exported by 0.56.0. It keeps the text-size bug until it is+ exported again from 0.57.1. Recommendation: re-export and push. Cost of not doing+ it: anyone reading the plan on the wiki still loses their text size on refresh.+2. **Cancelled rows and the start date.** Duration and Remaining leave cancelled rows+ out. The start button still counts them, because it shifts the whole plan. On a plan+ whose earliest row is cancelled the two dates differ; each tooltip says why.+ Recommendation: keep. The alternative is a start button that moves rows it does not+ show.+3. **Time Remaining before the plan starts** counts from today to the plan's end, so it+ can exceed Duration, with a visible "not started" note. Recommendation: keep, since+ the request was for a figure that accounts for today's date.+4. **Phone width.** At 393px the header takes four rows and the legend chips become+ colour dots. Everything is reachable, but it is not a designed phone layout.+ Recommendation: leave until someone actually uses the plan on a phone.+5. **The suite now takes 12 to 14 minutes.** `--only` runs a subset. Recommendation:+ subset while working, full run before a publish.++## Key decisions made during the build++* **Text size had two causes, not one.** The plan suspected the snapshot. The builder+ reproduced a second cause in the live app (the stale tab) before changing any code.+* **Merge-on-write preferences.** The first fix synced only the text size between tabs.+ The adversary showed the same overwrite still destroyed the collapse state, so the+ fix moved to the root: a save writes only the keys this tab changed.+* **A measured header fit.** The first build used pixel breakpoints, and the adversary+ showed the readouts clipped or hidden at 1280, 1366 and 1680. Breakpoints go stale+ the moment someone adds a button, so the header now measures itself.+* **Collapse all lives in the legend,** because the legend is the one row present in+ both the chart and the table.+* **Cancelled rows are out of the clock,** matching auto-schedule, the critical path+ and the hours totals.+* **Print names the plan.** Removing the title from the header left a printed chart+ anonymous, so a print-only title line carries it.+* **Beyond the brief:** the start month picker no longer turns the 31st into 3 March;+ an empty plan no longer throws a page error; one Ctrl+Z after the full editor's Apply+ now restores the section as well, and one Ctrl+Z takes back a whole batch Apply or a+ multi-bar drag where it used to restore a single row (both inherited from 0.56.0, both+ caught by the hardened suite).++---++## Process appendix++**Task:** six header and view changes plus a test suite. **Stage:** published.+**Rounds:** 2. **Models:** Builder Opus, Adversary Opus, Verifier Sonnet. Round 2 used+three narrow reviewers in parallel (two Opus, one Sonnet) and a separate test hardener.++### Adversary findings resolved++| Finding | Location | Severity | Resolution |+|---|---|---|---|+| Readouts clipped or hidden at common widths | gantt.html header CSS | High | Fixed: measured fit |+| Controls off screen above the wrap point | gantt.html header CSS | Med | Fixed: measured fit |+| Stale tab overwrites every pref except text size | savePrefs | Med | Fixed: merge-on-write |+| Printed chart does not name the plan | header, print CSS | Med | Fixed: print-only title |+| Cancelled rows inflate the clock | planSpan | Med | Fixed: left out |+| Remaining exceeds Duration before the start | readout | Low | Kept, visible note added |+| Viewport matrix too sparse to catch the clipping | ui_test.mjs | Med | Fixed: width sweep |+| Suite trusts whatever answers on its port | ui_test.mjs | Low | Fixed: free-port probe |+| Stored array swallows the snapshot's write | storedPrefs | Low | Fixed |+| Start button inexact for year-month data | syncStartBtn | Low | Fixed |+| Logs would ship in the package | .adomignore | Low | Fixed: excluded |+| Failed save marked as saved, never retried | savePrefs | Low-Med | Fixed in 0.57.1 |+| Invalid stored values linger | startup | Low | Fixed in 0.57.1 |+| Cleared store only partly restored | savePrefs | Low | Fixed in 0.57.1 |+| Label-width drag across startup lost | label resize | Low | Fixed in 0.57.1 |+| Wrapped header hides button words for nothing | fitHeader | Med | Fixed in 0.57.1 |+| Header changes state when web fonts arrive | fitHeader | Low-Med | Accepted; settles in one step |+| Error gate ignored failed own-server requests | ui_test.mjs | Med | Fixed in 0.57.1 |+| Visibility checks passed invisible elements | ui_test.mjs | Med | Fixed in 0.57.1 |+| Manifest: fictional flows, broad selectors, unwalked editors | ui_test.mjs | Med | Fixed in 0.57.1 |+| Undo after full-editor Apply keeps the new section | imApply | Med | Fixed in 0.57.1 |+| Batch Apply and multi-bar drag push one undo entry per row | snapshotMany | Med | Fixed in 0.57.1 |++### Behavioral verification++| Flow exercised | What the verifier did | Result | Notes |+|---|---|---|---|+| Collapse all | Clicked, mixed states, 10 and 11 rapid clicks, reload | Pass | |+| Text size | Reload, two tabs, snapshot over http and file | Pass | |+| Header declutter | Compared with 0.56.0 at every width | Pass | |+| Exact start date | All five cell sizes | Pass | |+| Duration and Remaining | Hand-computed, then edits and undo | Pass | 116 and 108 days |+| Legend | 20 chips, click on and off, scroll | Pass | 3 rows at 1440 |+| Widths 393 to 1920, text 85 to 150% | Overflow and overlap checks | Pass | |+| Edge plans | Future, ended, cancelled, empty, one phase | Pass | |+| Print, light theme, lenses | Emulated print media | Pass | Real paper not tested |++### Convergence++| Round | Adversary findings | Verifier | Suite (orchestrator's own run) |+|---|---|---|---|+| 1 | 11 (1 High, 5 Med, 5 Low) | not yet run | 201 passed, 0 failed |+| 1 fixes | | Pass, all flows | 222 passed, 0 failed (real plan); 205, 0 (synthetic) |+| 2 | 2 app (header), 4 app (prefs), 11 suite | | 318 passed, 1 failed: the hardened suite caught the batch undo bug |+| 2 fixes | | | 320 passed, 0 failed (real plan); 298, 0 (synthetic); 7 controls declared untested |+
gantt.html+74−8@@ -387,8 +387,8 @@ header #btnTheme { min-width: 56px; justify-content: center; } is wider than the header it sheds one step at a time and measures again, taking the first state that fits: the version (it is on the brand tooltip), then the button words (the old 1620px set; every button keeps its icon and tooltip), then the long- labels ("Duration", "Remaining"), and last it wraps onto a second row with the words- back. The readouts never shrink (.subject is flex: 1 0 auto), so they are never the+ labels ("Duration", "Remaining"), and last it wraps onto a second row, with the words+ back whenever they cost no extra row. The readouts never shrink (.subject is flex: 1 0 auto), so they are never the thing that gives. The classes below are the only switches; no rule here names a width. */ header.shed-ver .subject .app-ver { display: none; } header.shed-labels .who-name,@@ -1740,16 +1740,50 @@ function msShowsLine(){ return prefs.msStyle !== 'diamond'; } function prefsAsBase(){ const b={}; Object.keys(prefs).forEach(k=>{ b[k]=JSON.stringify(prefs[k]); }); return b; } let prefBase=prefsAsBase(); function rebasePrefs(){ prefBase=prefsAsBase(); }+// Two things a review of the merge found. The baseline moves only AFTER the write has+// landed: moved before it, a write the browser refused (quota, blocked storage) was+// marked as saved and never tried again, which lost a text size exactly the way this+// release set out to stop. And a store that is empty or unreadable (cleared, or garbage+// from somewhere else) holds nothing newer to protect, so this tab writes everything it+// has rather than one key, and a refresh opens the way the tab looked.+function prefStoreUsable(){+ try { const o = JSON.parse(localStorage.getItem(PREF_KEY)); return !!o && typeof o === 'object' && !Array.isArray(o); } catch { return false; }+} function savePrefs(){ try {+ const whole = !prefStoreUsable(); const all = storedPrefs();+ const next = {}; let dirty = false; Object.keys(prefs).forEach(k => { if (SNAPSHOT && READER_PREFS.indexOf(k) < 0) return; const j = JSON.stringify(prefs[k]);- if (j === prefBase[k]) return;- all[k] = prefs[k]; prefBase[k] = j; dirty = true;+ if (j === undefined) return;+ if (j === prefBase[k] && !whole) return;+ if (j !== prefBase[k]) dirty = true;+ all[k] = prefs[k]; next[k] = j; });+ if (!dirty) return;+ localStorage.setItem(PREF_KEY, JSON.stringify(all));+ Object.assign(prefBase, next);+ } catch {}+}+// A stored value the clamps had to correct (a size that is not a step, a milestone+// style with no CSS, the retired plan-wide critical mode) is corrected in storage too,+// once, at startup. Only a value that is INVALID is rewritten: a valid one another tab+// wrote is newer than this page and is left alone, and so is the critical mode this+// page dropped in memory because its plan has no dependencies.+function healStoredPrefs(){+ try {+ if (!prefStoreUsable()) return;+ const all = storedPrefs();+ let dirty = false;+ const fix = (k, ok, v) => { if (all[k] !== undefined && !ok(all[k])) { all[k] = v; dirty = true; } };+ fix('fs', v => FS_STEPS.indexOf(v) >= 0, FS_STEPS.indexOf(prefs.fs) >= 0 ? prefs.fs : 1);+ if (!SNAPSHOT) {+ fix('msStyle', v => MS_STYLES.indexOf(v) >= 0, 'line');+ fix('critMode', v => CRIT_MODES.indexOf(v) >= 0, all.critMode === 'global' ? 'phase' : 'off');+ } if (dirty) localStorage.setItem(PREF_KEY, JSON.stringify(all)); } catch {} }@@ -2229,7 +2263,11 @@ fitHeader(); // "Fits" is two questions, because the readouts must be WHOLE and not merely present: // nothing in the header runs past its right edge (scrollWidth, which counts content // that overflows a box that does not clip), and each readout is as wide as its text.-const HDR_STEPS=[[],['shed-ver'],['shed-ver','shed-labels'],['shed-ver','shed-labels','shed-keys'],['shed-ver','shed-labels','is-wrapped']];+const HDR_STEPS=[[],['shed-ver'],['shed-ver','shed-labels'],['shed-ver','shed-labels','shed-keys']];+// Wrapped, there are two candidates, and width cannot choose between them: a wrapping+// row always "fits". HEIGHT chooses. The button words come back when they cost no+// extra row, and stay off when they would (a phone, where they add a row or two).+const HDR_WRAP_WORDS=['shed-ver','is-wrapped'], HDR_WRAP_ICONS=['shed-ver','shed-labels','is-wrapped']; const HDR_ALL=['shed-ver','shed-labels','shed-keys','is-wrapped']; function headerFits(h){ if(h.scrollWidth>h.clientWidth+0.5)return false;@@ -2241,11 +2279,17 @@ function headerFits(h){ let hdrFitKey=''; function fitHeader(){ const h=$('hdr'); if(!h||!h.firstElementChild)return;- let pick=HDR_STEPS[HDR_STEPS.length-1];+ let pick=null; for(const step of HDR_STEPS){ h.classList.remove(...HDR_ALL); h.classList.add(...step); if(headerFits(h)){ pick=step; break; } }+ if(!pick){+ h.classList.remove(...HDR_ALL); h.classList.add(...HDR_WRAP_ICONS);+ const iconsH=h.offsetHeight;+ h.classList.remove(...HDR_ALL); h.classList.add(...HDR_WRAP_WORDS);+ pick=(headerFits(h)&&h.offsetHeight<=iconsH+0.5)?HDR_WRAP_WORDS:HDR_WRAP_ICONS;+ } h.classList.remove(...HDR_ALL); h.classList.add(...pick); hdrFitKey=pick.join(' '); }@@ -2402,7 +2446,15 @@ const UNDO_TOAST_DEFAULT='Undid change · <kbd>Ctrl+Z</kbd>'; let undoTimer = null; // The snapshot carries owner, status and description too, so a table edit is one // Ctrl+Z like every other edit rather than a second class of change.-function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)pushUndo({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name});});}+// ONE gesture, ONE undo entry (0.57.1). This used to push an entry per row, so a batch+// Apply over five tasks, or a drag of five selected bars, took five Ctrl+Z to take back+// and the first of them restored one row and left the other four where they had been+// moved. A single row is still a plain edit entry, exactly as before.+function editSnapshot(r){ return {type:'edit',id:r.id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name}; }+function snapshotMany(ids){+ const steps=ids.map(id=>items.find(d=>d.id===id)).filter(Boolean).map(editSnapshot);+ if(steps.length)pushUndo(steps.length===1?steps[0]:{type:'multi',steps});+} function snapshot(id){snapshotMany([id]);} // One undo entry covering many rows, for bulk operations like a plan shift. function snapshotGroup(ids){@@ -7947,8 +7999,18 @@ function openItemModal(r,carriedName){ $('imApply').addEventListener('click',()=>{ const name=$('imName').value.trim(); if(!name){const er=$('imErr');er.textContent='A name is required.';er.style.display='';$('imName').focus();return;}- if(ms)snapshotMs(r); else snapshot(r.id); const np=parseInt($('imPhase').value,10);+ const moves=!!phases[np]&&np!==r.phase;+ if(ms)snapshotMs(r); else snapshot(r.id);+ // ONE Ctrl+Z takes the whole Apply back, the section included. The field snapshot+ // does not carry the lane or the row's place in the plan, and moveRowToPhase records+ // nothing of its own, so until 0.57.1 an undo restored every field and left the row+ // in the section it had been moved to. The two halves go in as one entry, the way+ // the table's phase cell already records its move.+ if(moves){+ const ed=undoStack.pop();+ pushUndo({type:'multi',steps:[{type:'phase',r,phase:r.phase,order:items.map(rowOrderKey)},ed]});+ } r.name=name; const end=$('imEnd').value; if(ms){ if(end)r.date=end; }@@ -8360,6 +8422,9 @@ if(prefs.labelW) applyLabelWidth(prefs.labelW); document.addEventListener('mouseup',()=>{ if(!dragging)return; dragging=false; rz.classList.remove('dragging'); document.body.classList.remove('col-resizing');+ // The drag sets prefs.labelW as it goes and saves once here. If startup re-took+ // the baseline mid-drag, the new width would look already saved; forget it first.+ delete prefBase.labelW; savePrefs(); }); // Double-click: measure the widest rendered name and fit to it.@@ -10264,6 +10329,7 @@ if(critOn()&&!planHasDeps()){ } if(critOn())refreshCritical(); if(prefs.view==='analytics')renderAnalytics();+healStoredPrefs(); rebasePrefs(); // Say it after the first paint, so the message sits over a chart that clearly // still works rather than over a blank page.
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.57.0",+ "version": "0.57.1", "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
page.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.57.0",+ "version": "0.57.1", "description": "Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, pushing work LATER only so a lead-time gap you set on purpose is never closed, and one more lights up the critical path, meaning the longest run of work inside every section at once. Cancelled work is left out of both: it never moves, it constrains nothing and it can never glow. Dependency arrows are coloured by the phase of the task they come out of, so where a chain starts reads at a glance. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. Copy and paste rows: Ctrl+C the selected tasks and milestones, Ctrl+V to drop clones directly below a selected row or at the end of the lane you are looking at, with dependencies AMONG the copied rows remapped onto the copies so a duplicated chain is still a chain, links pointing outside it left naming the originals, and the whole paste one undo and one changelog line. Five statuses in lifecycle order, not started, in progress, complete, blocked, cancelled, with cancelled keeping the row and its history on the plan while taking its hours out of every total. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
test/ui_test.mjs+1128−494@@ -1,8 +1,11 @@ #!/usr/bin/env node-// adom-gantt browser suite (0.57.0). Real Chrome, real server, seeded temp data.+// adom-gantt browser suite (0.57.1). Real Chrome, real server, seeded temp data. // // node test/ui_test.mjs everything: flows at 1440, viewport matrix, coverage gate, leak watch // node test/ui_test.mjs --keep also leave screenshots and the temp dir (test/out/ + printed path)+// node test/ui_test.mjs --only <s> run only the flows whose name contains <s> (comma-separated+// list allowed); the whole-suite gates (coverage by-registry,+// untested count) are reported only on a full run // node test/ui_test.mjs --plan <p> additionally run the header/legend/duration checks against a // real plan file (it is COPIED to a temp dir first; the server // writes sidecars beside whatever file it is given)@@ -12,7 +15,11 @@ // the effect of each behaviour rather than the presence of an element, walks every // state for a control-coverage manifest (no orphans, no fiction, click every safe // control), renders a phone and two desktop widths, pins today's date in the page,-// always cleans up, and exits non-zero on any failure.+// always cleans up (also on SIGINT/SIGTERM, and a watchdog aborts a hung run after+// UI_TEST_WATCHDOG_MS, default 30 min), and exits non-zero on any failure.+// Failed requests are judged by URL: any 4xx/5xx or failed request to the app's own+// origin fails the run unless ALLOWED_FAILURES names it. Manifest entries with no+// effect-proving flow are marked `untested` and counted in the summary. // // Layers NOT built here: the protocol/integration layer (this app has one process, the // server, and its API is exercised through the UI), golden-image visual regression@@ -32,6 +39,10 @@ const ROOT = dirname(HERE); const argv = process.argv.slice(2); const KEEP = argv.includes('--keep'); const REAL_PLAN = argv.includes('--plan') ? argv[argv.indexOf('--plan') + 1] : null;+const ONLY = argv.includes('--only') ? argv[argv.indexOf('--only') + 1].split(',').filter(Boolean) : null;+// A flow block runs when no --only is given or its name contains one of the --only terms.+const want = name => !ONLY || ONLY.some(o => name.includes(o));+const wantAny = (...names) => names.some(want); const OUT = join(HERE, 'out'); const TMP = mkdtempSync(join(tmpdir(), 'adom-gantt-ui-')); if (KEEP) mkdirSync(OUT, { recursive: true });@@ -48,7 +59,12 @@ const CHROME = process.env.CHROME_PATH || (() => { let pass = 0, fail = 0; const failures = []; let section = '';+// Every flow (section) that executed at least one check. A terminal manifest entry's+// `by` must name one of these, so a `by` cannot point at a flow that does not exist+// or did not run.+const flowsRan = new Set(); function check(name, ok, detail = '') {+ if (section) flowsRan.add(section); const label = section ? `[${section}] ${name}` : name; if (ok) { pass++; console.log(` ok ${label}`); } else { fail++; failures.push(`${label}${detail ? ' :: ' + detail : ''}`); console.log(` FAIL ${label}${detail ? '\n ' + detail : ''}`); }@@ -189,18 +205,37 @@ async function startServer(plan, name) { // ── Pages: error gate, pinned clock, input modality ── const pageErrors = [];+// Designed failures from the app's own server, each named with the reason. Empty: the+// app serves nothing that is meant to fail in these states. Status 0 = request failed.+const ALLOWED_FAILURES = []; async function openPage(browser, url, { w = 1440, h = 900, mobile = false, today = TODAY, clear = true, ctx = null } = {}) { const page = await (ctx || browser).newPage(); const tag = `${w}px ${url.replace(/^.*\/\/[^/]+/, '').slice(0, 40) || '/'}`; page.on('pageerror', e => pageErrors.push(`[${tag}] pageerror: ${e.message}`)); page.on('console', m => { if (m.type() === 'error') { const t = m.text();- // Fonts come from Google and there is no network here; a failed font fetch is the- // container, not the app. Everything else is an error.- if (/fonts\.(googleapis|gstatic)\.com|net::ERR_(NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|CONNECTION_REFUSED)/.test(t)) return;- if (/Failed to load resource/.test(t) && !/127\.0\.0\.1/.test(t)) return;+ // A failed resource is judged by its URL, in the response/requestfailed handlers+ // below: Chrome's console line for a 404 or a 500 carries no URL, so it cannot be+ // told apart here. Everything else logged as an error is an error.+ if (/^Failed to load resource/.test(t)) return; pageErrors.push(`[${tag}] console: ${t.slice(0, 200)}`); } });+ // The app's own origin (its server, or file:// for an exported snapshot) must never+ // fail a request unless ALLOWED_FAILURES names that exact, designed failure.+ // Third-party failures are ignored: the web fonts come from Google and the container+ // has no network.+ const own = u => { try { const x = new URL(u); return x.protocol === 'file:' || x.hostname === '127.0.0.1' || x.hostname === 'localhost'; } catch { return false; } };+ const allowed = (u, status) => ALLOWED_FAILURES.some(a => a.path.test(new URL(u).pathname) && a.status === status);+ page.on('response', res => {+ const u = res.url(), st = res.status();+ if (st >= 400 && own(u) && !allowed(u, st)) pageErrors.push(`[${tag}] HTTP ${st} ${res.request().method()} ${u.replace(/^https?:\/\/[^/]+/, '')}`);+ });+ page.on('requestfailed', req => {+ const u = req.url(), why = req.failure()?.errorText || '';+ // ERR_ABORTED is the browser cancelling its own request (a reload or a navigation+ // while it was in flight), not the server failing it.+ if (own(u) && why !== 'net::ERR_ABORTED' && !allowed(u, 0)) pageErrors.push(`[${tag}] request failed ${req.method()} ${u.replace(/^https?:\/\/[^/]+/, '')} (${why})`);+ }); await page.setViewport({ width: w, height: h, isMobile: mobile, hasTouch: mobile, deviceScaleFactor: 1 }); // Puppeteer's own emulateMediaFeatures refuses any-hover/any-pointer, so straight to CDP. const cdp = await page.createCDPSession();@@ -222,6 +257,13 @@ async function openPage(browser, url, { w = 1440, h = 900, mobile = false, today PinnedDate.now = () => Real.now() + off; PinnedDate.parse = Real.parse; PinnedDate.UTC = Real.UTC; globalThis.Date = PinnedDate; }, today);+ // The suite's strict visibility test, the same everywhere a check says "visible":+ // checkVisibility with every option (so opacity:0, visibility:hidden and a hidden+ // ancestor all count as hidden) AND a non-zero box.+ await page.evaluateOnNewDocument(() => {+ Object.defineProperty(window, '__uiVis', { value: e => !!e && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, opacityProperty: true, visibilityProperty: true })+ && (r => r.width > 0 && r.height > 0)(e.getBoundingClientRect()) });+ }); await page.goto(url, { waitUntil: 'networkidle0' }); if (clear) { await page.evaluate(() => { try { localStorage.clear(); } catch {} }); await page.reload({ waitUntil: 'networkidle0' }); } await settle(page);@@ -279,7 +321,7 @@ async function assertNoSidewaysScroll(page, what) { const o = await page.evaluate(() => { const d = document.documentElement; const out = [...document.querySelectorAll('#hdr button, #hdr select, #legend .legend-item, #legend select, #legend input, #legend button')]- .filter(e => e.checkVisibility && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }))+ .filter(e => window.__uiVis(e)) .map(e => ({ e, r: e.getBoundingClientRect() })) .filter(x => x.r.width > 0 && (x.r.right > innerWidth + 0.5 || x.r.left < -0.5)) .map(x => (x.e.id || x.e.className || x.e.tagName) + ' @' + Math.round(x.r.left) + '..' + Math.round(x.r.right));@@ -291,431 +333,511 @@ async function assertNoSidewaysScroll(page, what) { // ──────────────────────────────────────────────────────────────────────────── async function main() {- const browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new',+ const browser = BROWSER = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars'] }); try { const plan = seedPlan(); const S = await startServer(plan, 'plan20'); SRV = S.url;+ let page, r, lg; - // ── 3. Header declutter ──- section = 'header';- let page = await openPage(browser, S.url);- const hdr = await page.evaluate(() => ({- brand: document.querySelector('#hdr .brand .app-name').textContent,- brandTip: document.querySelector('#hdr .brand').title,- name: !!document.querySelector('.subject-name'), sub: !!document.querySelector('.subject-sub'),- unitCount: !!document.getElementById('unitCount'),- text: document.getElementById('hdr').innerText, docTitle: document.title,- }));- check('brand block shows the plan appName', hdr.brand === plan.project.appName, hdr.brand);- check('no bold title (.subject-name) in the header', !hdr.name);- check('no dimmed description (.subject-sub) in the header', !hdr.sub);- check('the title and subtitle are not painted anywhere in the header', !hdr.text.includes(plan.project.title) && !hdr.text.includes(plan.project.subtitle));- check('brand tooltip still carries the title and the subtitle', hdr.brandTip.includes(plan.project.title) && hdr.brandTip.includes(plan.project.subtitle), hdr.brandTip);- check('document.title still names the plan', hdr.docTitle.includes(plan.project.title), hdr.docTitle);- check('the old cell count (#unitCount) is gone', !hdr.unitCount);- check('header text has no "weeks" cell count', !/\b\d+ (weeks|months|days|quarters|years)\b/.test(hdr.text), hdr.text.replace(/\s+/g, ' ').slice(0, 160));- await shot(page, 'desktop-1440-header-legend');-- // ── 4 + 5. Exact start date and the two readouts, at every grain ──- section = 'dates';- const base = await expectReadouts(page, TODAY, 'on load');- check('on load: duration is 16w 4d (116 days, 21-Sep-2026..14-Jan-2027)', base.dur === '16w 4d', base.dur);- check('on load: remaining is 15w 3d (108 days from 29-Sep-2026)', base.left === '15w 3d', base.left);- check('duration tooltip states both exact dates', base.durTip.includes('21-Sep-2026') && base.durTip.includes('14-Jan-2027') && base.durTip.includes('116 calendar days'), base.durTip);- check('remaining tooltip states today and the end date', base.leftTip.includes('29-Sep-2026') && base.leftTip.includes('14-Jan-2027'), base.leftTip);- check('the cancelled row to 30-Jun-2027 is left out, and the tooltip says so', base.dur === '16w 4d' && /1 cancelled row left out/.test(base.durTip), base.durTip);- check('while running there is no "not started" note', await page.$eval('#spanLeftN', e => e.textContent) === '');- for (const g of ['day', 'week', 'month', 'quarter', 'year']) {- await setGrain(page, g);- const r = await readouts(page);- const cells = await page.$$eval('.m-row > *', e => e.length);- check(`grain ${g}: start shows the exact date 21-Sep-2026`, r.start === '21-Sep-2026', r.start);- check(`grain ${g}: duration and remaining do not move`, r.dur === base.dur && r.left === base.left, `${r.dur} / ${r.left}`);- check(`grain ${g}: the axis still redraws (${cells} cells)`, cells > 0);- }- await setGrain(page, 'week');-- // Start-date picker: opens, and a month pick lands on an exact date.- section = 'start picker';- await page.click('#startBtn'); await settle(page, 100);- check('the start-date picker opens', await page.$eval('#datePop', e => e.classList.contains('show')));- await page.click('#datePop .dp-cell[data-month="9"]'); await settle(page);- let r = await expectReadouts(page, TODAY, 'after moving the start to October');- check('start moved to 21-Oct-2026 (exact day kept)', r.start === '21-Oct-2026', r.start);- check('a whole-plan shift keeps the duration', r.dur === base.dur, r.dur);- check('a whole-plan shift moves the remaining time', r.left !== base.left, r.left);- await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);- r = await expectReadouts(page, TODAY, 'undo of the start shift');- check('undo puts the start back', r.start === '21-Sep-2026' && r.dur === base.dur && r.left === base.left, JSON.stringify(r));-- // Drag the last bar's right edge two weeks later.- section = 'live: drag';- const finaleBox = async () => page.evaluate(() => {- const b = document.querySelector('.bar[data-id="finale"]'); b.scrollIntoView({ block: 'center', inline: 'center' });- const h = b.querySelector('.drag-handle.right') || b; const r = h.getBoundingClientRect();- return { x: r.left + r.width / 2, y: r.top + r.height / 2, col: document.querySelector('.m-row > *').getBoundingClientRect().width };- });- let fb = await finaleBox(); await settle(page, 150); fb = await finaleBox();- await page.mouse.move(fb.x, fb.y); await page.mouse.down();- for (let i = 1; i <= 8; i++) { await page.mouse.move(fb.x + fb.col * 2 * i / 8, fb.y); await sleep(20); }- await page.mouse.up(); await settle(page, 300);- const afterDrag = await expectReadouts(page, TODAY, 'after dragging the last bar later');- check('the drag really moved the end of the plan', afterDrag.last > LAST, afterDrag.last);- check('duration grew with it', afterDrag.dur !== base.dur, afterDrag.dur);- section = 'live: undo/redo';- await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);- r = await expectReadouts(page, TODAY, 'undo');- check('undo restores 16w 4d', r.dur === base.dur, r.dur);- await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page);- r = await expectReadouts(page, TODAY, 'redo');- check('redo brings the dragged end back', r.dur === afterDrag.dur, r.dur);- await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);-- // Auto-schedule pushes "finale" after "gate-a", which moves the plan's last date.- section = 'live: auto-schedule';- await page.click('#btnAuto');- await page.waitForFunction(() => !document.querySelector('#btnAuto.btn-busy'), { timeout: 15000 }); await settle(page, 400);- r = await expectReadouts(page, TODAY, 'after Auto-schedule');- check('Auto-schedule moved the end of the plan', r.last !== LAST, r.last);- await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);- r = await expectReadouts(page, TODAY, 'undo of Auto-schedule');- check('undo of Auto-schedule restores 16w 4d', r.dur === base.dur, r.dur);-- // Table edit of the last row's deadline.- section = 'live: table edit';- await view(page, 'table');- const cellSel = 'tr[data-key] td.c-date[data-field="deadline"]';- const keyOfFinale = await page.evaluate(() => [...document.querySelectorAll('#tableView tr[data-key]')].find(tr => /Launch finale/.test(tr.textContent)).dataset.key);- await page.evaluate(k => document.querySelector(`tr[data-key="${k}"]`).scrollIntoView({ block: 'center' }), keyOfFinale);- // The undo toast from the step before sits over the bottom of the table, and the- // finale row is near the bottom (the cancelled row sorts after it); a person would- // wait for it too.- await page.waitForFunction(() => !document.querySelector('#undoToast.show'), { timeout: 8000 }).catch(() => {});- await page.click(`tr[data-key="${keyOfFinale}"] td.c-date[data-field="deadline"]`); await settle(page, 100);- await page.evaluate(() => { const i = document.querySelector('.cell-edit input'); i.value = '2027-01-21'; i.dispatchEvent(new Event('input', { bubbles: true })); });- await page.keyboard.press('Enter'); await settle(page, 300);- r = await expectReadouts(page, TODAY, 'after a table deadline edit');- check('table edit moved the end to 21-Jan-2027 (17w 4d)', r.last === '2027-01-21' && r.dur === '17w 4d', `${r.last} ${r.dur}`);- await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);- r = await expectReadouts(page, TODAY, 'undo of the table edit');-- // Delete the last row, then undo.- section = 'live: delete';- await page.evaluate(k => document.querySelector(`tr[data-key="${k}"]`).scrollIntoView({ block: 'center' }), keyOfFinale);- await page.hover(`tr[data-key="${keyOfFinale}"]`);- await page.$eval(`tr[data-key="${keyOfFinale}"] .row-del`, b => b.click()); await settle(page, 300);- r = await expectReadouts(page, TODAY, 'after deleting the last row');- check('deleting the last row shortened the plan', r.last < LAST, r.last);- await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);- r = await expectReadouts(page, TODAY, 'undo of the delete');- check('undo of the delete restores 16w 4d', r.dur === base.dur, r.dur);-- // Paste: copy the last row and paste it; the copy has the same dates, so the span- // is unchanged, and the readouts must still agree with the plan.- section = 'live: paste';- await view(page, 'gantt');- const nBefore = r.n;- const lbl = await page.evaluateHandle(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => /Launch finale/.test(x.textContent)); e.scrollIntoView({ block: 'center' }); return e; });- await settle(page, 100);- const lb = await lbl.boundingBox();- // Ctrl+click selects a row without opening its name editor (a plain click edits).- await page.keyboard.down('Control'); await page.mouse.click(lb.x + 40, lb.y + lb.height / 2); await page.keyboard.up('Control'); await settle(page, 100);- await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control');- r = await expectReadouts(page, TODAY, 'after paste');- check('paste added a row', r.n === nBefore + 1, `${nBefore} -> ${r.n}`);- await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);-- // Create a task past the end with the New editor.- section = 'live: create';- await view(page, 'gantt');- await page.click('#btnNew'); await settle(page, 150);- await page.type('#ncName', 'Post-launch retro');- await page.evaluate(() => { const s = document.getElementById('ncStart'), e = document.getElementById('ncEnd'); s.value = '2027-01-25'; e.value = '2027-01-29'; s.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('input', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); });- await page.click('#ncCreate'); await settle(page, 800);- r = await expectReadouts(page, TODAY, 'after creating a task past the end');- check('the new task moved the end to 29-Jan-2027', r.last === '2027-01-29', r.last);- await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 300);- r = await expectReadouts(page, TODAY, 'undo of the create');- check('undo of the create restores 16w 4d', r.dur === base.dur, r.dur);- await page.close();+ if (wantAny('header', 'dates', 'start picker', 'live')) {+ // ── 3. Header declutter ──+ section = 'header';+ page = await openPage(browser, S.url);+ const hdr = await page.evaluate(() => ({+ brand: document.querySelector('#hdr .brand .app-name').textContent,+ brandTip: document.querySelector('#hdr .brand').title,+ name: !!document.querySelector('.subject-name'), sub: !!document.querySelector('.subject-sub'),+ unitCount: !!document.getElementById('unitCount'),+ text: document.getElementById('hdr').innerText, docTitle: document.title,+ }));+ check('brand block shows the plan appName', hdr.brand === plan.project.appName, hdr.brand);+ check('no bold title (.subject-name) in the header', !hdr.name);+ check('no dimmed description (.subject-sub) in the header', !hdr.sub);+ check('the title and subtitle are not painted anywhere in the header', !hdr.text.includes(plan.project.title) && !hdr.text.includes(plan.project.subtitle));+ check('brand tooltip still carries the title and the subtitle', hdr.brandTip.includes(plan.project.title) && hdr.brandTip.includes(plan.project.subtitle), hdr.brandTip);+ check('document.title still names the plan', hdr.docTitle.includes(plan.project.title), hdr.docTitle);+ check('the old cell count (#unitCount) is gone', !hdr.unitCount);+ check('header text has no "weeks" cell count', !/\b\d+ (weeks|months|days|quarters|years)\b/.test(hdr.text), hdr.text.replace(/\s+/g, ' ').slice(0, 160));+ await shot(page, 'desktop-1440-header-legend'); - // Plan not started yet, and plan over.- section = 'dates: clock';- page = await openPage(browser, S.url, { today: '2026-09-01' });- r = await readouts(page);- check('before the plan starts: remaining counts from today (136 days = 19w 3d)', r.left === '19w 3d', r.left);- check('before the plan starts: state is not-started and the tooltip says so', r.leftState === 'not-started' && /not started/.test(r.leftTip), r.leftTip);- check('before the plan starts: duration unchanged', r.dur === '16w 4d', r.dur);- check('before the plan starts: the readout itself says "not started"', await page.$eval('#spanLeftN', e => e.textContent) === 'not started' && await page.$eval('#spanLeftN', e => e.checkVisibility()));- await page.close();- page = await openPage(browser, S.url, { today: '2027-02-01' });- r = await readouts(page);- check('after the plan ends: remaining reads "ended"', r.left === 'ended' && r.leftState === 'ended', r.left);- check('after the plan ends: tooltip says how long ago', /18 days ago/.test(r.leftTip), r.leftTip);- await page.close();- page = await openPage(browser, S.url, { today: LAST });- r = await readouts(page);- check('on the last day: 1 day remaining', r.left === '1 day', r.left);- // A tab left open overnight: the date rolls over with no edit at all, and coming- // back to the tab repaints (the minute tick does the same for a tab left in view).- await page.evaluate(() => { window.__advanceClockDays(1); document.dispatchEvent(new Event('visibilitychange')); });- await settle(page, 100);- r = await readouts(page);- check('the day after, with no edit: remaining turns to "ended"', r.left === 'ended', r.left);- await page.close();+ // ── 4 + 5. Exact start date and the two readouts, at every grain ──+ section = 'dates';+ const base = await expectReadouts(page, TODAY, 'on load');+ check('on load: duration is 16w 4d (116 days, 21-Sep-2026..14-Jan-2027)', base.dur === '16w 4d', base.dur);+ check('on load: remaining is 15w 3d (108 days from 29-Sep-2026)', base.left === '15w 3d', base.left);+ check('duration tooltip states both exact dates', base.durTip.includes('21-Sep-2026') && base.durTip.includes('14-Jan-2027') && base.durTip.includes('116 calendar days'), base.durTip);+ check('remaining tooltip states today and the end date', base.leftTip.includes('29-Sep-2026') && base.leftTip.includes('14-Jan-2027'), base.leftTip);+ check('the cancelled row to 30-Jun-2027 is left out, and the tooltip says so', base.dur === '16w 4d' && /1 cancelled row left out/.test(base.durTip), base.durTip);+ check('while running there is no "not started" note', await page.$eval('#spanLeftN', e => e.textContent) === '');+ const axes = [];+ for (const g of ['day', 'week', 'month', 'quarter', 'year']) {+ await setGrain(page, g);+ const r = await readouts(page);+ const ax = await page.evaluate(() => { const cs = [...document.querySelectorAll('.m-row > *')]; return { n: cs.length, labels: cs.slice(0, 4).map(c => c.textContent.trim()).join('|') }; });+ axes.push({ g, ...ax });+ check(`grain ${g}: start shows the exact date 21-Sep-2026`, r.start === '21-Sep-2026', r.start);+ check(`grain ${g}: duration and remaining do not move`, r.dur === base.dur && r.left === base.left, `${r.dur} / ${r.left}`);+ }+ // The axis must REDRAW for the grain: finer grains have more cells (day > week >+ // month >= quarter >= year) and no two grains paint the same cells.+ const [dA, wA, mA, qA, yA] = axes;+ check('the axis redraws with the grain: day > week > month >= quarter >= year cells', dA.n > wA.n && wA.n > mA.n && mA.n >= qA.n && qA.n >= yA.n && yA.n > 0, axes.map(a => `${a.g}:${a.n}`).join(' '));+ check('the axis redraws with the grain: every grain paints different cells', new Set(axes.map(a => a.n + '#' + a.labels)).size === 5, axes.map(a => `${a.g}:${a.labels}`).join(' ; '));+ await setGrain(page, 'week'); - // ── 2. Text size persists: live app ──- section = 'text size (live)';- page = await openPage(browser, S.url);- await page.click('#fsUp'); await page.click('#fsUp'); await settle(page);- check('A+ twice reads 120%', await fsPct(page) === '120%');- const legendBefore = (await legendGeometry(page)).L.h;- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);- check('120% survives a refresh', await fsPct(page) === '120%');- check('and it is applied, not just displayed', await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--fs').trim()) === '1.2');- await assertSticky(page, 'at 120%');- await page.click('#fsDown'); await page.click('#fsDown'); await settle(page);- check('A- twice is back at 100%', await fsPct(page) === '100%');- await page.click('#fsUp'); await page.click('#fsPct'); await settle(page);- check('clicking the percentage resets to 100%', await fsPct(page) === '100%');- await page.close();+ // Start-date picker: opens, and a month pick lands on an exact date.+ section = 'start picker';+ await page.click('#startBtn'); await settle(page, 100);+ check('the start-date picker opens', await page.$eval('#datePop', e => e.classList.contains('show')));+ await page.click('#datePop .dp-cell[data-month="9"]'); await settle(page);+ r = await expectReadouts(page, TODAY, 'after moving the start to October');+ check('start moved to 21-Oct-2026 (exact day kept)', r.start === '21-Oct-2026', r.start);+ check('a whole-plan shift keeps the duration', r.dur === base.dur, r.dur);+ check('a whole-plan shift moves the remaining time', r.left !== base.left, r.left);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of the start shift');+ check('undo puts the start back', r.start === '21-Sep-2026' && r.dur === base.dur && r.left === base.left, JSON.stringify(r));+ // Revert and Today, the picker's two links.+ await page.click('#startBtn'); await settle(page, 100);+ check('with the start unmoved, Revert is disabled', await page.$eval('#datePop .dp-link[data-act="revert"]', b => b.disabled));+ await page.click('#datePop .dp-cell[data-month="9"]'); await settle(page);+ await page.click('#startBtn'); await settle(page, 100);+ await page.click('#datePop .dp-link[data-act="revert"]'); await settle(page);+ r = await expectReadouts(page, TODAY, 'after Revert');+ check('Revert puts the plan back on its original start', r.start === '21-Sep-2026' && r.dur === base.dur, r.start);+ await page.click('#startBtn'); await settle(page, 100);+ await page.click('#datePop .dp-link[data-act="today"]'); await settle(page);+ r = await expectReadouts(page, TODAY, 'after Today');+ check('Today (at week grain) moves the start to the 1st of this month, 01-Sep-2026', r.start === '01-Sep-2026' && r.dur === base.dur, r.start);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of Today');+ check('undo of Today goes back to the original start', r.start === '21-Sep-2026' && r.dur === base.dur && r.left === base.left, r.start); - // Cause 2: a second tab opened BEFORE the change used to write its stale size back.- section = 'text size (two tabs)';- const ctx = await browser.createBrowserContext();- const A = await openPage(browser, S.url, { ctx });- const B = await openPage(browser, S.url, { ctx, clear: false });- await B.click('#fsUp'); await B.click('#fsUp'); await settle(B);- await settle(A, 300);- check('the other open tab follows the new size live', await fsPct(A) === '120%', await fsPct(A));- await A.bringToFront(); await A.select('#grainSel', 'month'); await settle(A);- await B.bringToFront(); await B.reload({ waitUntil: 'networkidle0' }); await settle(B);- check('a save in the other tab no longer resets the size on refresh', await fsPct(B) === '120%', await fsPct(B));- // Every other pref too (review finding): tab A collapses everything, tab B, opened- // before that, toggles the theme. B used to write its whole prefs object, empty- // collapse set included, and A's refresh came back with every lane open.- await A.bringToFront(); await A.click('#btnCollapseAll'); await settle(A);- await B.bringToFront(); await B.click('#btnTheme'); await settle(B);- await A.bringToFront(); await A.reload({ waitUntil: 'networkidle0' }); await settle(A);- check('another tab saving the theme no longer reopens every lane', await A.$$eval('.lane-toggle.collapsed', e => e.length) === 20, String(await A.$$eval('.lane-toggle.collapsed', e => e.length)));- check('and the theme that tab chose is kept too', await A.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'light');- check('and the text size is still 120%', await fsPct(A) === '120%');- await A.close(); await B.close(); await ctx.close();-- // Cause 1: the exported snapshot never read the size back.- section = 'text size (snapshot)';- const html = await (await fetch(S.url + 'export/html')).text();- check('export/html produced a snapshot', html.includes('const SNAPSHOT = true;'));- const snapFile = join(TMP, 'snapshot.html'); writeFileSync(snapFile, html);- const snapUrl = 'file://' + snapFile;- page = await openPage(browser, snapUrl);- // A reader who has other prefs stored from some other chart:- await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', JSON.stringify({ granularity: 'day', theme: 'light', fs: 1.1, collapsed: [0, 1, 2] })));- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);- check('the snapshot takes the reader\'s text size (110%)', await fsPct(page) === '110%', await fsPct(page));- check('the snapshot still opens at the author\'s grain', await page.$eval('#grainSel', s => s.value) === 'week');- check('the snapshot still opens at the author\'s theme', await page.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'dark');- check('the snapshot ignores a stored collapse set', await page.$$eval('.lane-toggle.collapsed', e => e.length) === 0);- await page.click('#fsUp'); await settle(page);- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);- check('A+ in the snapshot survives a refresh (120%)', await fsPct(page) === '120%', await fsPct(page));- const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs')));- check('the snapshot did not overwrite the reader\'s other stored prefs', stored.granularity === 'day' && stored.theme === 'light' && stored.fs === 1.2, JSON.stringify(stored));- // A stored value that parses to an ARRAY used to swallow the snapshot's write.- await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', '[1,2,3]'));- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);- await page.click('#fsUp'); await settle(page);- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);- check('a stored array does not swallow the size (110% after A+ from 100%)', await fsPct(page) === '110%', await fsPct(page));- const snapR = await readouts(page);- check('the snapshot shows the same readouts', snapR.dur === '16w 4d' && snapR.left === '15w 3d' && snapR.start === '21-Sep-2026', JSON.stringify(snapR));- await page.close();+ // Drag the last bar's right edge two weeks later.+ section = 'live: drag';+ const finaleBox = async () => page.evaluate(() => {+ const b = document.querySelector('.bar[data-id="finale"]'); b.scrollIntoView({ block: 'center', inline: 'center' });+ const h = b.querySelector('.drag-handle.right') || b; const r = h.getBoundingClientRect();+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, col: document.querySelector('.m-row > *').getBoundingClientRect().width };+ });+ let fb = await finaleBox(); await settle(page, 150); fb = await finaleBox();+ await page.mouse.move(fb.x, fb.y); await page.mouse.down();+ for (let i = 1; i <= 8; i++) { await page.mouse.move(fb.x + fb.col * 2 * i / 8, fb.y); await sleep(20); }+ await page.mouse.up(); await settle(page, 300);+ const afterDrag = await expectReadouts(page, TODAY, 'after dragging the last bar later');+ check('the drag really moved the end of the plan', afterDrag.last > LAST, afterDrag.last);+ check('duration grew with it', afterDrag.dur !== base.dur, afterDrag.dur);+ section = 'live: undo/redo';+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo');+ check('undo restores 16w 4d', r.dur === base.dur, r.dur);+ await page.keyboard.down('Control'); await page.keyboard.down('Shift'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Shift'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'redo');+ check('redo brings the dragged end back', r.dur === afterDrag.dur, r.dur);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page); - // ── 1. Collapse / expand all ──- section = 'collapse all';- page = await openPage(browser, S.url);- const collapseState = () => page.evaluate(() => ({- label: document.querySelector('#btnCollapseAll .ca-label').textContent,- pressed: document.getElementById('btnCollapseAll').getAttribute('aria-pressed'),- lanes: document.querySelectorAll('.lane-toggle').length,- shut: document.querySelectorAll('.lane-toggle.collapsed').length,- visibleRows: [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.offsetHeight > 0).length,- }));- let c = await collapseState();- check('starts as "Collapse all" with every lane open', c.label === 'Collapse all' && c.shut === 0 && c.visibleRows > 100, JSON.stringify(c));- await page.click('#btnCollapseAll'); await settle(page);- c = await collapseState();- check('one press collapses every lane', c.shut === c.lanes && c.lanes === 20, JSON.stringify(c));- check('and hides every task and milestone row', c.visibleRows === 0, String(c.visibleRows));- check('the button now offers "Expand all"', c.label === 'Expand all' && c.pressed === 'true');- await page.reload({ waitUntil: 'networkidle0' }); await settle(page);- c = await collapseState();- check('collapse-all persists across a refresh', c.shut === 20 && c.label === 'Expand all', JSON.stringify(c));- await view(page, 'table');- const emptyMsg = await page.$eval('#tableView tbody', t => t.innerText);- check('the table agrees: no rows, and says why', /Every section is collapsed/.test(emptyMsg), emptyMsg.slice(0, 80));- await page.click('#btnCollapseAll'); await settle(page);- const tRows = await page.$$eval('#tableView tbody tr[data-key]', e => e.length);- const nItems = spanOf(await exportPlan(S.url)).n;- check('Expand all from the table brings every row back', tRows === nItems, `${tRows} of ${nItems}`);- await view(page, 'gantt');- c = await collapseState();- check('and the chart agrees: every lane open', c.shut === 0 && c.label === 'Collapse all', JSON.stringify(c));- // Mixed state: one lane shut by hand, then the button.- await page.click('.label-row.group .lane-toggle'); await settle(page);- c = await collapseState();- check('per-lane chevron still collapses one lane', c.shut === 1, JSON.stringify(c));- check('mixed state offers "Collapse all"', c.label === 'Collapse all');- await page.click('#btnCollapseAll'); await settle(page);- c = await collapseState();- check('from mixed, one press collapses the rest', c.shut === 20, JSON.stringify(c));- // Open every lane by hand except... all of them: the button follows the chevrons.- await page.evaluate(() => document.querySelectorAll('.lane-toggle.collapsed').forEach(b => b.click())); await settle(page);- c = await collapseState();- check('opening lanes by hand turns the button back to "Collapse all"', c.shut === 0 && c.label === 'Collapse all', JSON.stringify(c));- await page.close();+ // Auto-schedule pushes "finale" after "gate-a", which moves the plan's last date.+ section = 'live: auto-schedule';+ await page.click('#btnAuto');+ await page.waitForFunction(() => !document.querySelector('#btnAuto.btn-busy'), { timeout: 15000 }); await settle(page, 400);+ r = await expectReadouts(page, TODAY, 'after Auto-schedule');+ check('Auto-schedule moved the end of the plan', r.last !== LAST, r.last);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of Auto-schedule');+ check('undo of Auto-schedule restores 16w 4d', r.dur === base.dur, r.dur); - // ── 6. Legend wraps ──- section = 'legend';- page = await openPage(browser, S.url);- let lg = await legendGeometry(page);- check('20 phase chips rendered', lg.chips.length === 20, String(lg.chips.length));- check('the legend wraps onto more than one line', lg.lines >= 2, `${lg.lines} line(s), ${lg.L.h}px`);- check('every chip is inside the legend and the window', lg.chips.every(c => c.right <= lg.L.right + 0.5 && c.bottom <= lg.L.bottom + 0.5 && c.right <= lg.vw), JSON.stringify(lg.chips.filter(c => c.right > lg.L.right || c.bottom > lg.L.bottom).slice(0, 2)));- check('every chip is clickable (nothing covers it)', lg.chips.every(c => c.clickable));- check('the chart starts right below the legend', Math.abs(lg.chartTop - lg.L.bottom) <= 1.5, `${lg.chartTop} vs ${lg.L.bottom}`);- // The last chip is the one a one-line legend clipped; filter with it.- const last = (await page.$$('#legend .phase-chip')).at(-1);- await last.click(); await settle(page);- const dimmed = await page.$$eval('.label-row.dim:not(.group)', e => e.length);- check('clicking the 20th chip filters its lane (its 8 rows dim, nothing else)', dimmed === 8 && await page.evaluate(() => document.querySelectorAll('#legend .phase-chip')[19].classList.contains('off')), `${dimmed} dimmed`);- await last.click(); await settle(page);- await assertSticky(page, '1440 wrapped legend');- // Taller legend at 150% text, then a narrower window, then back.- for (let i = 0; i < 4; i++) await page.click('#fsUp');- await settle(page, 400);- lg = await legendGeometry(page);- check('at 150% the legend grows and still shows every chip', lg.lines >= 3 && lg.chips.every(c => c.clickable && c.bottom <= lg.L.bottom + 0.5), `${lg.lines} lines`);- await assertSticky(page, '150% text');- await page.setViewport({ width: 1100, height: 900 }); await settle(page, 500);- lg = await legendGeometry(page);- check('resized to 1100: chart still meets the legend', Math.abs(lg.chartTop - lg.L.bottom) <= 1.5);- await assertSticky(page, 'resized to 1100');- await page.setViewport({ width: 1440, height: 900 }); await settle(page, 500);- await assertSticky(page, 'resized back to 1440');- await page.click('#fsPct'); await settle(page, 300);- // Table: its sticky header must stay at the top of its own scroller under a wrapped legend.- await view(page, 'table');- const th = await page.evaluate(async () => {- const head = document.querySelector('#tableView thead th');- let sc = head.parentElement; while (sc && sc !== document.body && !(sc.scrollHeight > sc.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(sc).overflowY))) sc = sc.parentElement;- sc.scrollTop = 600; await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));- const o = { scrolled: sc.scrollTop, thTop: head.getBoundingClientRect().top, scTop: sc.getBoundingClientRect().top, legendBottom: document.getElementById('legend').getBoundingClientRect().bottom };- sc.scrollTop = 0; return o;- });- check('table header sticks to the top of the table under the legend', th.scrolled > 100 && Math.abs(th.thTop - th.scTop) <= 2 && th.thTop >= th.legendBottom - 0.5, JSON.stringify(th));- await shot(page, 'desktop-1440-table');- await page.close();+ // Table edit of the last row's deadline.+ section = 'live: table edit';+ await view(page, 'table');+ const cellSel = 'tr[data-key] td.c-date[data-field="deadline"]';+ const keyOfFinale = await page.evaluate(() => [...document.querySelectorAll('#tableView tr[data-key]')].find(tr => /Launch finale/.test(tr.textContent)).dataset.key);+ await page.evaluate(k => document.querySelector(`tr[data-key="${k}"]`).scrollIntoView({ block: 'center' }), keyOfFinale);+ // The undo toast from the step before sits over the bottom of the table, and the+ // finale row is near the bottom (the cancelled row sorts after it); a person would+ // wait for it too.+ await page.mouse.move(700, 120);+ await page.waitForFunction(() => !document.querySelector('#undoToast.show'), { timeout: 8000 });+ await page.click(`tr[data-key="${keyOfFinale}"] td.c-date[data-field="deadline"]`); await settle(page, 100);+ await page.evaluate(() => { const i = document.querySelector('.cell-edit input'); i.value = '2027-01-21'; i.dispatchEvent(new Event('input', { bubbles: true })); });+ await page.keyboard.press('Enter'); await settle(page, 300);+ r = await expectReadouts(page, TODAY, 'after a table deadline edit');+ check('table edit moved the end to 21-Jan-2027 (17w 4d)', r.last === '2027-01-21' && r.dur === '17w 4d', `${r.last} ${r.dur}`);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of the table edit'); - // ── Print: the plan is named on paper even though the header no longer paints it ──- section = 'print';- page = await openPage(browser, S.url);- await page.emulateMediaType('print'); await settle(page, 200);- const pr = await page.evaluate(() => ({- title: (() => { const e = document.getElementById('printTitle'); return e && e.checkVisibility() ? e.innerText : ''; })(),- ca: document.getElementById('btnCollapseAll').checkVisibility(),- }));- check('a printed chart carries the title and subtitle', pr.title.includes(plan.project.title) && pr.title.includes(plan.project.subtitle), pr.title);- check('Collapse all does not print', !pr.ca);- await page.emulateMediaType('screen'); await settle(page, 150);- check('the print title is not on screen', !(await page.$eval('#printTitle', e => e.checkVisibility())));- await page.close();+ // Delete the last row, then undo.+ section = 'live: delete';+ await page.evaluate(k => document.querySelector(`tr[data-key="${k}"]`).scrollIntoView({ block: 'center' }), keyOfFinale);+ // The delete button is a hover affordance (opacity 0 until the row is hovered):+ // hover the row like a person, check it became visible, then click it with the mouse.+ // The undo toast from the step before sits over the bottom of the table, where+ // this row is; a person waits for it before reaching for the row, and so does this.+ // The toast pauses while the pointer is over it (by design), and the pointer is still+ // where the last click left it, so it is moved off first.+ await page.mouse.move(700, 120);+ await page.waitForFunction(() => !document.querySelector('#undoToast.show'), { timeout: 8000 });+ await page.hover(`tr[data-key="${keyOfFinale}"]`); await settle(page, 200);+ // A live repaint can swap the row under a still pointer; a real hand is never+ // perfectly still, so nudge the pointer inside the row, as it would.+ const trb = await (await page.$(`tr[data-key="${keyOfFinale}"]`)).boundingBox(); await page.mouse.move(trb.x + trb.width / 2 + 3, trb.y + trb.height / 2); await settle(page, 200);+ const rd = await page.$eval(`tr[data-key="${keyOfFinale}"] .row-del`, b => ({ vis: window.__uiVis(b), op: getComputedStyle(b).opacity, hov: b.closest('tr').matches(':hover'), top: document.elementFromPoint(b.getBoundingClientRect().left + 5, b.getBoundingClientRect().top + 5)?.className }));+ check('hovering the row reveals its delete button', rd.vis && rd.op === '1', JSON.stringify(rd));+ await page.click(`tr[data-key="${keyOfFinale}"] .row-del`); await settle(page, 300);+ r = await expectReadouts(page, TODAY, 'after deleting the last row');+ check('deleting the last row shortened the plan', r.last < LAST, r.last);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page);+ r = await expectReadouts(page, TODAY, 'undo of the delete');+ check('undo of the delete restores 16w 4d', r.dur === base.dur, r.dur); - // ── All cancelled, and a plan written in months ──- section = 'edge plans';- const AC = await startServer({ project: { title: 'Called Off', appName: 'called-off' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],- items: [{ type: 'task', id: 'a', name: 'Was the plan', start: '2026-10-05', end: '2026-10-09', phase: 0, status: 'cancelled', uid: '00001' }], }, 'allcancelled');- page = await openPage(browser, AC.url);- r = await readouts(page);- check('every row cancelled: both readouts say none, and why', r.dur === 'none' && r.left === 'none' && /Every dated row in the plan is cancelled/.test(r.durTip), JSON.stringify(r));- await page.close();- const MO = await startServer({ project: { title: 'Month Plan', appName: 'month-plan' }, phases: [{ name: 'Only', start: '2026-10', end: '2026-11' }],- items: [{ type: 'task', id: 'a', name: 'Written in months', start: '2026-10', end: '2026-11', phase: 0, uid: '00001' }], }, 'months');- page = await openPage(browser, MO.url);- r = await readouts(page);- check('year-month data: the start button still shows a day (01-Oct-2026)', r.start === '01-Oct-2026', r.start);- check('year-month data: duration runs 01-Oct to 30-Nov (61 days = 8w 5d)', r.dur === '8w 5d' && /01-Oct-2026/.test(r.durTip) && /30-Nov-2026/.test(r.durTip), `${r.dur} ${r.durTip}`);- await page.close();+ // Paste: copy the last row and paste it; the copy has the same dates, so the span+ // is unchanged, and the readouts must still agree with the plan.+ section = 'live: paste';+ await view(page, 'gantt');+ const nBefore = r.n;+ const lbl = await page.evaluateHandle(() => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => /Launch finale/.test(x.textContent)); e.scrollIntoView({ block: 'center' }); return e; });+ await settle(page, 100);+ const lb = await lbl.boundingBox();+ // Ctrl+click selects a row without opening its name editor (a plain click edits).+ await page.keyboard.down('Control'); await page.mouse.click(lb.x + 40, lb.y + lb.height / 2); await page.keyboard.up('Control'); await settle(page, 100);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyC'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control');+ r = await expectReadouts(page, TODAY, 'after paste');+ check('paste added a row', r.n === nBefore + 1, `${nBefore} -> ${r.n}`);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page); - // ── Empty plan, and a plan with no appName ──- section = 'empty plan';- const E = await startServer({ project: { title: 'Nothing Scheduled Yet', subtitle: 'An empty plan' }, phases: [{ name: 'Only lane', start: '2026-09', end: '2026-10' }], items: [] }, 'empty');- page = await openPage(browser, E.url);- r = await readouts(page);- check('empty plan: both readouts say none', r.dur === 'none' && r.left === 'none', JSON.stringify(r));- check('empty plan: start button says there are no dates', r.start === 'no dates', r.start);- check('no appName: the brand shows the project title', await page.$eval('#hdr .app-name', e => e.textContent) === 'Nothing Scheduled Yet');- check('empty plan: Collapse all is disabled (nothing to hide)', await page.$eval('#btnCollapseAll', b => b.disabled));- await page.close();+ // Create a task past the end with the New editor.+ section = 'live: create';+ await view(page, 'gantt');+ await page.click('#btnNew'); await settle(page, 150);+ await page.type('#ncName', 'Post-launch retro');+ await page.evaluate(() => { const s = document.getElementById('ncStart'), e = document.getElementById('ncEnd'); s.value = '2027-01-25'; e.value = '2027-01-29'; s.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('input', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); });+ await page.click('#ncCreate'); await settle(page, 800);+ r = await expectReadouts(page, TODAY, 'after creating a task past the end');+ check('the new task moved the end to 29-Jan-2027', r.last === '2027-01-29', r.last);+ await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 300);+ r = await expectReadouts(page, TODAY, 'undo of the create');+ check('undo of the create restores 16w 4d', r.dur === base.dur, r.dur);+ await page.close();+ } - // ── Viewport matrix ──- for (const vp of [{ w: 393, h: 852, mobile: true }, { w: 1440, h: 900 }, { w: 1920, h: 1080 }]) {- section = `viewport ${vp.w}`;- page = await openPage(browser, S.url, vp);- await assertNoSidewaysScroll(page, 'chart');- lg = await legendGeometry(page);- check('all 20 phase chips visible and clickable', lg.chips.length === 20 && lg.chips.every(c => c.clickable && c.right <= vp.w));+ if (wantAny('dates: clock')) {+ // Plan not started yet, and plan over.+ section = 'dates: clock';+ page = await openPage(browser, S.url, { today: '2026-09-01' });+ r = await readouts(page);+ check('before the plan starts: remaining counts from today (136 days = 19w 3d)', r.left === '19w 3d', r.left);+ check('before the plan starts: state is not-started and the tooltip says so', r.leftState === 'not-started' && /not started/.test(r.leftTip), r.leftTip);+ check('before the plan starts: duration unchanged', r.dur === '16w 4d', r.dur);+ check('before the plan starts: the readout itself says "not started"', await page.$eval('#spanLeftN', e => e.textContent) === 'not started' && await page.$eval('#spanLeftN', e => window.__uiVis(e)));+ await page.close();+ page = await openPage(browser, S.url, { today: '2027-02-01' });+ r = await readouts(page);+ check('after the plan ends: remaining reads "ended"', r.left === 'ended' && r.leftState === 'ended', r.left);+ check('after the plan ends: tooltip says how long ago', /18 days ago/.test(r.leftTip), r.leftTip);+ await page.close();+ page = await openPage(browser, S.url, { today: LAST });+ r = await readouts(page);+ check('on the last day: 1 day remaining', r.left === '1 day', r.left);+ // A tab left open overnight: the date rolls over with no edit at all, and coming+ // back to the tab repaints (the minute tick does the same for a tab left in view).+ await page.evaluate(() => { window.__advanceClockDays(1); document.dispatchEvent(new Event('visibilitychange')); });+ await settle(page, 100); r = await readouts(page);- check('duration readouts present', r.dur === '16w 4d' && r.left === '15w 3d', JSON.stringify(r));- const readoutVisible = await page.evaluate(() => { const e = document.getElementById('spanLeftV'); const x = e.getBoundingClientRect(); const s = document.querySelector('#hdr .subject').getBoundingClientRect();- return e.checkVisibility() && x.width > 0 && x.right <= innerWidth && x.right <= s.right + 0.5; });- check('Time Remaining is on screen and not clipped', readoutVisible);+ check('the day after, with no edit: remaining turns to "ended"', r.left === 'ended', r.left);+ await page.close();+ }++ if (wantAny('text size')) {+ // ── 2. Text size persists: live app ──+ section = 'text size (live)';+ page = await openPage(browser, S.url);+ await page.click('#fsUp'); await page.click('#fsUp'); await settle(page);+ check('A+ twice reads 120%', await fsPct(page) === '120%');+ const legendBefore = (await legendGeometry(page)).L.h;+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ check('120% survives a refresh', await fsPct(page) === '120%');+ check('and it is applied, not just displayed', await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--fs').trim()) === '1.2');+ await assertSticky(page, 'at 120%');+ await page.click('#fsDown'); await page.click('#fsDown'); await settle(page);+ check('A- twice is back at 100%', await fsPct(page) === '100%');+ await page.click('#fsUp'); await page.click('#fsPct'); await settle(page);+ check('clicking the percentage resets to 100%', await fsPct(page) === '100%');+ await page.close();++ // Cause 2: a second tab opened BEFORE the change used to write its stale size back.+ section = 'text size (two tabs)';+ const ctx = await browser.createBrowserContext();+ const A = await openPage(browser, S.url, { ctx });+ const B = await openPage(browser, S.url, { ctx, clear: false });+ await B.click('#fsUp'); await B.click('#fsUp'); await settle(B);+ await settle(A, 300);+ check('the other open tab follows the new size live', await fsPct(A) === '120%', await fsPct(A));+ await A.bringToFront(); await A.select('#grainSel', 'month'); await settle(A);+ await B.bringToFront(); await B.reload({ waitUntil: 'networkidle0' }); await settle(B);+ check('a save in the other tab no longer resets the size on refresh', await fsPct(B) === '120%', await fsPct(B));+ // Every other pref too (review finding): tab A collapses everything, tab B, opened+ // before that, toggles the theme. B used to write its whole prefs object, empty+ // collapse set included, and A's refresh came back with every lane open.+ await A.bringToFront(); await A.click('#btnCollapseAll'); await settle(A);+ await B.bringToFront(); await B.click('#btnTheme'); await settle(B);+ await A.bringToFront(); await A.reload({ waitUntil: 'networkidle0' }); await settle(A);+ check('another tab saving the theme no longer reopens every lane', await A.$$eval('.lane-toggle.collapsed', e => e.length) === 20, String(await A.$$eval('.lane-toggle.collapsed', e => e.length)));+ check('and the theme that tab chose is kept too', await A.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'light');+ check('and the text size is still 120%', await fsPct(A) === '120%');+ await A.close(); await B.close(); await ctx.close();++ // Cause 1: the exported snapshot never read the size back.+ section = 'text size (snapshot)';+ const html = await (await fetch(S.url + 'export/html')).text();+ check('export/html produced a snapshot', html.includes('const SNAPSHOT = true;'));+ const snapFile = join(TMP, 'snapshot.html'); writeFileSync(snapFile, html);+ const snapUrl = 'file://' + snapFile;+ page = await openPage(browser, snapUrl);+ // A reader who has other prefs stored from some other chart:+ await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', JSON.stringify({ granularity: 'day', theme: 'light', fs: 1.1, collapsed: [0, 1, 2] })));+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ check('the snapshot takes the reader\'s text size (110%)', await fsPct(page) === '110%', await fsPct(page));+ check('the snapshot still opens at the author\'s grain', await page.$eval('#grainSel', s => s.value) === 'week');+ check('the snapshot still opens at the author\'s theme', await page.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'dark');+ check('the snapshot ignores a stored collapse set', await page.$$eval('.lane-toggle.collapsed', e => e.length) === 0);+ await page.click('#fsUp'); await settle(page);+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ check('A+ in the snapshot survives a refresh (120%)', await fsPct(page) === '120%', await fsPct(page));+ const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('adom-gantt:prefs')));+ check('the snapshot did not overwrite the reader\'s other stored prefs', stored.granularity === 'day' && stored.theme === 'light' && stored.fs === 1.2, JSON.stringify(stored));+ // A stored value that parses to an ARRAY used to swallow the snapshot's write.+ await page.evaluate(() => localStorage.setItem('adom-gantt:prefs', '[1,2,3]'));+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ await page.click('#fsUp'); await settle(page);+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ check('a stored array does not swallow the size (110% after A+ from 100%)', await fsPct(page) === '110%', await fsPct(page));+ const snapR = await readouts(page);+ check('the snapshot shows the same readouts', snapR.dur === '16w 4d' && snapR.left === '15w 3d' && snapR.start === '21-Sep-2026', JSON.stringify(snapR));+ await page.close();+ }++ if (wantAny('collapse all')) {+ // ── 1. Collapse / expand all ──+ section = 'collapse all';+ page = await openPage(browser, S.url);+ const collapseState = () => page.evaluate(() => ({+ label: document.querySelector('#btnCollapseAll .ca-label').textContent,+ pressed: document.getElementById('btnCollapseAll').getAttribute('aria-pressed'),+ lanes: document.querySelectorAll('.lane-toggle').length,+ shut: document.querySelectorAll('.lane-toggle.collapsed').length,+ visibleRows: [...document.querySelectorAll('.label-row:not(.group)')].filter(e => e.offsetHeight > 0).length,+ }));+ let c = await collapseState();+ check('starts as "Collapse all" with every lane open', c.label === 'Collapse all' && c.shut === 0 && c.visibleRows > 100, JSON.stringify(c));+ await page.click('#btnCollapseAll'); await settle(page);+ c = await collapseState();+ check('one press collapses every lane', c.shut === c.lanes && c.lanes === 20, JSON.stringify(c));+ check('and hides every task and milestone row', c.visibleRows === 0, String(c.visibleRows));+ check('the button now offers "Expand all"', c.label === 'Expand all' && c.pressed === 'true');+ await page.reload({ waitUntil: 'networkidle0' }); await settle(page);+ c = await collapseState();+ check('collapse-all persists across a refresh', c.shut === 20 && c.label === 'Expand all', JSON.stringify(c));+ await view(page, 'table');+ const emptyMsg = await page.$eval('#tableView tbody', t => t.innerText);+ check('the table agrees: no rows, and says why', /Every section is collapsed/.test(emptyMsg), emptyMsg.slice(0, 80)); await page.click('#btnCollapseAll'); await settle(page);- check('Collapse all works here', await page.evaluate(() => document.querySelectorAll('.lane-toggle.collapsed').length) === 20);+ const tRows = await page.$$eval('#tableView tbody tr[data-key]', e => e.length);+ const nItems = spanOf(await exportPlan(S.url)).n;+ check('Expand all from the table brings every row back', tRows === nItems, `${tRows} of ${nItems}`);+ await view(page, 'gantt');+ c = await collapseState();+ check('and the chart agrees: every lane open', c.shut === 0 && c.label === 'Collapse all', JSON.stringify(c));+ // Mixed state: one lane shut by hand, then the button.+ await page.click('.label-row.group .lane-toggle'); await settle(page);+ c = await collapseState();+ check('per-lane chevron still collapses one lane', c.shut === 1, JSON.stringify(c));+ check('mixed state offers "Collapse all"', c.label === 'Collapse all'); await page.click('#btnCollapseAll'); await settle(page);- await shot(page, `viewport-${vp.w}`);+ c = await collapseState();+ check('from mixed, one press collapses the rest', c.shut === 20, JSON.stringify(c));+ // Open every lane by hand except... all of them: the button follows the chevrons.+ await page.evaluate(() => document.querySelectorAll('.lane-toggle.collapsed').forEach(b => b.click())); await settle(page);+ c = await collapseState();+ check('opening lanes by hand turns the button back to "Collapse all"', c.shut === 0 && c.label === 'Collapse all', JSON.stringify(c));+ await page.close();+ }++ if (wantAny('legend')) {+ // ── 6. Legend wraps ──+ section = 'legend';+ page = await openPage(browser, S.url);+ lg = await legendGeometry(page);+ check('20 phase chips rendered', lg.chips.length === 20, String(lg.chips.length));+ check('the legend wraps onto more than one line', lg.lines >= 2, `${lg.lines} line(s), ${lg.L.h}px`);+ check('every chip is inside the legend and the window', lg.chips.every(c => c.right <= lg.L.right + 0.5 && c.bottom <= lg.L.bottom + 0.5 && c.right <= lg.vw), JSON.stringify(lg.chips.filter(c => c.right > lg.L.right || c.bottom > lg.L.bottom).slice(0, 2)));+ check('every chip is clickable (nothing covers it)', lg.chips.every(c => c.clickable));+ check('the chart starts right below the legend', Math.abs(lg.chartTop - lg.L.bottom) <= 1.5, `${lg.chartTop} vs ${lg.L.bottom}`);+ // The last chip is the one a one-line legend clipped; filter with it.+ const last = (await page.$$('#legend .phase-chip')).at(-1);+ await last.click(); await settle(page);+ const dimmed = await page.$$eval('.label-row.dim:not(.group)', e => e.length);+ check('clicking the 20th chip filters its lane (its 8 rows dim, nothing else)', dimmed === 8 && await page.evaluate(() => document.querySelectorAll('#legend .phase-chip')[19].classList.contains('off')), `${dimmed} dimmed`);+ await last.click(); await settle(page);+ await assertSticky(page, '1440 wrapped legend');+ // Taller legend at 150% text, then a narrower window, then back.+ for (let i = 0; i < 4; i++) await page.click('#fsUp');+ await settle(page, 400);+ lg = await legendGeometry(page);+ check('at 150% the legend grows and still shows every chip', lg.lines >= 3 && lg.chips.every(c => c.clickable && c.bottom <= lg.L.bottom + 0.5), `${lg.lines} lines`);+ await assertSticky(page, '150% text');+ await page.setViewport({ width: 1100, height: 900 }); await settle(page, 500);+ lg = await legendGeometry(page);+ check('resized to 1100: chart still meets the legend', Math.abs(lg.chartTop - lg.L.bottom) <= 1.5);+ await assertSticky(page, 'resized to 1100');+ await page.setViewport({ width: 1440, height: 900 }); await settle(page, 500);+ await assertSticky(page, 'resized back to 1440');+ await page.click('#fsPct'); await settle(page, 300);+ // Table: its sticky header must stay at the top of its own scroller under a wrapped legend. await view(page, 'table');- await assertNoSidewaysScroll(page, 'table');- await shot(page, `viewport-${vp.w}-table`);+ const th = await page.evaluate(async () => {+ const head = document.querySelector('#tableView thead th');+ let sc = head.parentElement; while (sc && sc !== document.body && !(sc.scrollHeight > sc.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(sc).overflowY))) sc = sc.parentElement;+ sc.scrollTop = 600; await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));+ const o = { scrolled: sc.scrollTop, thTop: head.getBoundingClientRect().top, scTop: sc.getBoundingClientRect().top, legendBottom: document.getElementById('legend').getBoundingClientRect().bottom };+ sc.scrollTop = 0; return o;+ });+ check('table header sticks to the top of the table under the legend', th.scrolled > 100 && Math.abs(th.thTop - th.scTop) <= 2 && th.thTop >= th.legendBottom - 0.5, JSON.stringify(th));+ await shot(page, 'desktop-1440-table'); await page.close(); } - // ── Header width sweep ──- // The two readouts Drew asked for must be whole at EVERY width, with the plan's- // appName and with the title fallback (no appName, a longer brand), at 100% and- // 150% text, and no header control may sit outside the window. The widths are a- // 40px sweep plus the ones real screens and the adversary review used; set- // SWEEP_STEP=20 for a denser pass.- section = 'header sweep';- const NA = await startServer(seedPlan({ appName: null, title: 'instaPCB Launch: Jan 11, 2027' }), 'no-appname');- await headerSweep(browser, [['appName', S.url], ['title fallback', NA.url]]);-- // ── Coverage gate ──- section = 'coverage';- await coverageGate(browser, S.url);-- // ── Leak watch (cheap): listeners and nodes across repeated toggles ──- section = 'leak';- page = await openPage(browser, S.url);- // Collect first, both times: Nodes and listeners count detached nodes until a GC- // runs, and when that happens is up to V8, so an uncollected run reads as a leak.- const gcCdp = await page.createCDPSession();- const metricsAfterGc = async () => { await gcCdp.send('HeapProfiler.collectGarbage'); await sleep(100); await gcCdp.send('HeapProfiler.collectGarbage'); return page.metrics(); };- // one warm-up round, so lazily built things (the table body, caches) are not counted as growth- await page.click('#btnCollapseAll'); await page.click('#btnCollapseAll'); await page.click('#fsUp'); await page.click('#fsDown'); await settle(page, 400);- const m0 = await metricsAfterGc();- for (let i = 0; i < 20; i++) { await page.click('#btnCollapseAll'); await page.click('#fsUp'); await page.click('#fsDown'); }- for (const g of ['day', 'month', 'week', 'quarter', 'week']) await setGrain(page, g);- await setGrain(page, 'week'); await settle(page, 600);- const m1 = await metricsAfterGc();- check('no listener growth over 20 collapse-all + 40 text-size + 5 grain changes', m1.JSEventListeners - m0.JSEventListeners <= 5, `${m0.JSEventListeners} -> ${m1.JSEventListeners}`);- check('DOM nodes bounded (back at the same grain, same count within 500)', Math.abs(m1.Nodes - m0.Nodes) < 500, `${m0.Nodes} -> ${m1.Nodes}`);- await page.close();+ if (wantAny('print')) {+ // ── Print: the plan is named on paper even though the header no longer paints it ──+ section = 'print';+ page = await openPage(browser, S.url);+ await page.emulateMediaType('print'); await settle(page, 200);+ const pr = await page.evaluate(() => ({+ title: (() => { const e = document.getElementById('printTitle'); return window.__uiVis(e) ? e.innerText : ''; })(),+ ca: window.__uiVis(document.getElementById('btnCollapseAll')),+ }));+ check('a printed chart carries the title and subtitle', pr.title.includes(plan.project.title) && pr.title.includes(plan.project.subtitle), pr.title);+ check('Collapse all does not print', !pr.ca);+ await page.emulateMediaType('screen'); await settle(page, 150);+ check('the print title is not on screen', !(await page.$eval('#printTitle', e => window.__uiVis(e))));+ await page.close();+ }++ if (wantAny('edge plans')) {+ // ── All cancelled, and a plan written in months ──+ section = 'edge plans';+ const AC = await startServer({ project: { title: 'Called Off', appName: 'called-off' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],+ items: [{ type: 'task', id: 'a', name: 'Was the plan', start: '2026-10-05', end: '2026-10-09', phase: 0, status: 'cancelled', uid: '00001' }], }, 'allcancelled');+ page = await openPage(browser, AC.url);+ r = await readouts(page);+ check('every row cancelled: both readouts say none, and why', r.dur === 'none' && r.left === 'none' && /Every dated row in the plan is cancelled/.test(r.durTip), JSON.stringify(r));+ await page.close();+ const MO = await startServer({ project: { title: 'Month Plan', appName: 'month-plan' }, phases: [{ name: 'Only', start: '2026-10', end: '2026-11' }],+ items: [{ type: 'task', id: 'a', name: 'Written in months', start: '2026-10', end: '2026-11', phase: 0, uid: '00001' }], }, 'months');+ page = await openPage(browser, MO.url);+ r = await readouts(page);+ check('year-month data: the start button still shows a day (01-Oct-2026)', r.start === '01-Oct-2026', r.start);+ check('year-month data: duration runs 01-Oct to 30-Nov (61 days = 8w 5d)', r.dur === '8w 5d' && /01-Oct-2026/.test(r.durTip) && /30-Nov-2026/.test(r.durTip), `${r.dur} ${r.durTip}`);+ await page.close();+ }++ if (wantAny('empty plan')) {+ // ── Empty plan, and a plan with no appName ──+ section = 'empty plan';+ const E = await startServer({ project: { title: 'Nothing Scheduled Yet', subtitle: 'An empty plan' }, phases: [{ name: 'Only lane', start: '2026-09', end: '2026-10' }], items: [] }, 'empty');+ page = await openPage(browser, E.url);+ r = await readouts(page);+ check('empty plan: both readouts say none', r.dur === 'none' && r.left === 'none', JSON.stringify(r));+ check('empty plan: start button says there are no dates', r.start === 'no dates', r.start);+ check('no appName: the brand shows the project title', await page.$eval('#hdr .app-name', e => e.textContent) === 'Nothing Scheduled Yet');+ check('empty plan: Collapse all is disabled (nothing to hide)', await page.$eval('#btnCollapseAll', b => b.disabled));+ await page.close();+ }++ // The error gate tests itself: a failed request to the app's own server must be+ // caught even though Chrome's console line for it carries no URL, and a failed+ // third-party request (the web fonts) must not be.+ if (wantAny('error gate')) {+ section = 'error gate';+ page = await openPage(browser, S.url);+ const n0 = pageErrors.length;+ await page.evaluate(() => Promise.all([fetch('no-such-path-ui-test').catch(() => {}),+ new Promise(r => { const i = new Image(); i.onload = i.onerror = r; i.src = 'https://ui-test.invalid/missing.png'; })]));+ await settle(page, 300);+ const caught = pageErrors.slice(n0);+ check('a 404 from the app\'s own server is caught by URL', caught.some(e => /HTTP 404 GET \/no-such-path-ui-test/.test(e)), caught.join(' | ') || 'nothing caught');+ check('a failed third-party resource is not', caught.length === 1, caught.join(' | '));+ pageErrors.splice(n0); // these two were planted; the final gate must judge the rest+ await page.close();+ }+ if (wantAny('editor', 'new item fields', 'table filters')) await editorFlows(browser, S);+ if (wantAny('export menu', 'import menu')) await menuFlows(browser, S);+ if (wantAny('sections')) await sectionsFlow(browser);++ if (wantAny('viewport')) {+ // ── Viewport matrix ──+ for (const vp of [{ w: 393, h: 852, mobile: true }, { w: 1440, h: 900 }, { w: 1920, h: 1080 }]) {+ section = `viewport ${vp.w}`;+ page = await openPage(browser, S.url, vp);+ await assertNoSidewaysScroll(page, 'chart');+ lg = await legendGeometry(page);+ check('all 20 phase chips visible and clickable', lg.chips.length === 20 && lg.chips.every(c => c.clickable && c.right <= vp.w));+ r = await readouts(page);+ check('duration readouts present', r.dur === '16w 4d' && r.left === '15w 3d', JSON.stringify(r));+ const readoutVisible = await page.evaluate(() => { const e = document.getElementById('spanLeftV'); const x = e.getBoundingClientRect(); const s = document.querySelector('#hdr .subject').getBoundingClientRect();+ return window.__uiVis(e) && x.width > 0 && x.right <= innerWidth && x.right <= s.right + 0.5; });+ check('Time Remaining is on screen and not clipped', readoutVisible);+ await page.click('#btnCollapseAll'); await settle(page);+ check('Collapse all works here', await page.evaluate(() => document.querySelectorAll('.lane-toggle.collapsed').length) === 20);+ await page.click('#btnCollapseAll'); await settle(page);+ await shot(page, `viewport-${vp.w}`);+ await view(page, 'table');+ await assertNoSidewaysScroll(page, 'table');+ await shot(page, `viewport-${vp.w}-table`);+ await page.close();+ }+ }++ if (wantAny('header sweep')) {+ // ── Header width sweep ──+ // The two readouts Drew asked for must be whole at EVERY width, with the plan's+ // appName and with the title fallback (no appName, a longer brand), at 100% and+ // 150% text, and no header control may sit outside the window. The widths are a+ // 40px sweep plus the ones real screens and the adversary review used; set+ // SWEEP_STEP=20 for a denser pass.+ section = 'header sweep';+ const NA = await startServer(seedPlan({ appName: null, title: 'instaPCB Launch: Jan 11, 2027' }), 'no-appname');+ await headerSweep(browser, [['appName', S.url], ['title fallback', NA.url]]);+ }++ if (wantAny('coverage')) {+ // ── Coverage gate ──+ section = 'coverage';+ await coverageGate(browser, S.url);+ }++ if (wantAny('leak')) {+ // ── Leak watch (cheap): listeners and nodes across repeated toggles ──+ section = 'leak';+ page = await openPage(browser, S.url);+ // Collect first, both times: Nodes and listeners count detached nodes until a GC+ // runs, and when that happens is up to V8, so an uncollected run reads as a leak.+ const gcCdp = await page.createCDPSession();+ const metricsAfterGc = async () => { await gcCdp.send('HeapProfiler.collectGarbage'); await sleep(100); await gcCdp.send('HeapProfiler.collectGarbage'); return page.metrics(); };+ // one warm-up round, so lazily built things (the table body, caches) are not counted as growth+ await page.click('#btnCollapseAll'); await page.click('#btnCollapseAll'); await page.click('#fsUp'); await page.click('#fsDown'); await settle(page, 400);+ const m0 = await metricsAfterGc();+ for (let i = 0; i < 20; i++) { await page.click('#btnCollapseAll'); await page.click('#fsUp'); await page.click('#fsDown'); }+ for (const g of ['day', 'month', 'week', 'quarter', 'week']) await setGrain(page, g);+ await setGrain(page, 'week'); await settle(page, 600);+ const m1 = await metricsAfterGc();+ check('no listener growth over 20 collapse-all + 40 text-size + 5 grain changes', m1.JSEventListeners - m0.JSEventListeners <= 5, `${m0.JSEventListeners} -> ${m1.JSEventListeners}`);+ check('DOM nodes bounded (back at the same grain, same count within 500)', Math.abs(m1.Nodes - m0.Nodes) < 500, `${m0.Nodes} -> ${m1.Nodes}`);+ await page.close();+ } // ── Optional: the real plan ──- if (REAL_PLAN) {+ if (REAL_PLAN && want('real plan')) { section = 'real plan'; const R = await startServer(REAL_PLAN, 'real'); SRV = R.url;@@ -740,16 +862,29 @@ async function main() { } section = '';- check('NO uncaught page errors or console errors on any page', pageErrors.length === 0, pageErrors.slice(0, 5).join(' | '));+ if (!ONLY) UNTESTED = manifestHonesty();+ check('NO uncaught page errors, console errors or failed app requests on any page', pageErrors.length === 0, pageErrors.slice(0, 5).join(' | ')); } finally { await browser.close().catch(() => {}); } } +// The header controls each lens must show: PRESENT, visible (strictly: no opacity:0,+// no visibility:hidden, a non-zero box) and wholly inside the window, at every swept+// width. A control a lens hides on purpose is listed in HDR_HIDDEN and must be hidden.+const HDR_COMMON = ['.brand', '.vt-btn[data-view="gantt"]', '.vt-btn[data-view="table"]', '.vt-btn[data-view="analytics"]',+ '#spanDur', '#spanLeft', '#btnTheme', '#btnSections', '#btnImport', '#btnExport', '#btnHelp', '#btnNew', '#fsDown', '#fsPct', '#fsUp'];+const HDR_CHART_ONLY = ['#startBtn', '#grainSel', '#btnFit', '#btnDeps', '#btnMsStyle'];+const HDR_EXPECT = {+ gantt: [...HDR_COMMON, ...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'],+ table: [...HDR_COMMON, '#btnAuto', '#btnCrit'],+ analytics: [...HDR_COMMON],+};+const HDR_HIDDEN = { gantt: [], table: HDR_CHART_ONLY, analytics: [...HDR_CHART_ONLY, '#btnAuto', '#btnCrit'] }; // Everything that could hide the readout: an ancestor that clips, the window edge, or // the text itself being cut (a span whose content is wider than its box).-async function headerProbe(page) {- return page.evaluate(() => {+async function headerProbe(page, lens = 'gantt') {+ return page.evaluate((expect, hidden) => { const clipRect = el => { let r = el.getBoundingClientRect(); let L = r.left, R = r.right; for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {@@ -761,47 +896,88 @@ async function headerProbe(page) { const bad = []; for (const id of ['spanDur', 'spanLeft']) { const e = document.getElementById(id);- if (!e || !e.checkVisibility()) { bad.push(id + ' hidden'); continue; }+ if (!window.__uiVis(e)) { bad.push(id + ' hidden'); continue; } const c = clipRect(e); if (c.full.width < 20 || c.L > c.full.left + 0.5 || c.R < c.full.right - 0.5 || c.full.right > innerWidth + 0.5) bad.push(`${id} clipped ${Math.round(c.full.left)}..${Math.round(c.full.right)} visible ${Math.round(c.L)}..${Math.round(c.R)}`); for (const k of e.children) if (k.scrollWidth > k.clientWidth + 1) bad.push(id + ' text cut'); }- const off = [...document.querySelectorAll('#hdr button, #hdr select')].filter(e => e.checkVisibility())+ // Every expected control: present, strictly visible, non-zero, inside the window,+ // and not clipped by an ancestor.+ const off = [];+ for (const sel of expect) {+ const e = document.querySelector('#hdr ' + sel);+ if (!e) { off.push(sel + ' missing'); continue; }+ if (!window.__uiVis(e)) { off.push(sel + ' hidden or zero-size'); continue; }+ const c = clipRect(e);+ if (c.full.right > innerWidth + 0.5 || c.full.left < -0.5) off.push(`${sel} outside the window ${Math.round(c.full.left)}..${Math.round(c.full.right)}`);+ else if (c.L > c.full.left + 0.5 || c.R < c.full.right - 0.5) off.push(`${sel} clipped`);+ }+ for (const sel of hidden) { const e = document.querySelector('#hdr ' + sel); if (e && window.__uiVis(e)) off.push(sel + ' shown in a lens that hides it'); }+ // And no OTHER header control may be off-screen either (one this list does not know).+ [...document.querySelectorAll('#hdr button, #hdr select')].filter(e => window.__uiVis(e)) .map(e => ({ e, r: e.getBoundingClientRect() })).filter(x => x.r.right > innerWidth + 0.5 || x.r.left < -0.5)- .map(x => (x.e.id || x.e.className) + '@' + Math.round(x.r.right));+ .forEach(x => off.push((x.e.id || x.e.className) + ' @' + Math.round(x.r.right))); return { bad, off, text: document.getElementById('spanDur')?.innerText.replace(/\s+/g, ' ') + ' / ' + document.getElementById('spanLeft')?.innerText.replace(/\s+/g, ' ') };- });+ }, HDR_EXPECT[lens], HDR_HIDDEN[lens]); } async function headerSweep(browser, targets) { const step = +(process.env.SWEEP_STEP || 40); const widths = new Set([393, 1181, 1210, 1250, 1280, 1330, 1366, 1392, 1440, 1500, 1536, 1620, 1680, 1745, 1830]); for (let w = 360; w <= 2000; w += step) widths.add(w); const W = [...widths].sort((a, b) => a - b);- for (const [label, url] of targets) {- for (const fs of [1, 1.5]) {- const page = await openPage(browser, url, { w: 1440, h: 900 });- if (fs !== 1) { for (let i = 0; i < 4; i++) await page.click('#fsUp'); await settle(page, 200); }- const fails = [];- for (const w of W) {- await page.setViewport({ width: w, height: 900 });- await settle(page, 120);- const p = await headerProbe(page);- if (p.bad.length || p.off.length) fails.push(`${w}px: ${[...p.bad, ...p.off.map(o => 'off-screen ' + o)].join(', ')}`);+ // Every target in the chart lens at 100% and 150%; the other two lenses (fewer+ // controls, so less at stake) on the first target at 100% and 150%.+ const runs = [];+ for (const [label, url] of targets) for (const fs of [1, 1.5]) runs.push([label, url, fs, 'gantt']);+ for (const lens of ['table', 'analytics']) for (const fs of [1, 1.5]) runs.push([targets[0][0], targets[0][1], fs, lens]);+ let selfTested = false;+ for (const [label, url, fs, lens] of runs) {+ const page = await openPage(browser, url, { w: 1440, h: 900 });+ if (!selfTested) {+ // The probe tests itself once: a control hidden four different ways must be flagged.+ selfTested = true;+ const planted = [['display', 'none', 'hidden or zero-size'], ['visibility', 'hidden', 'hidden or zero-size'], ['opacity', '0', 'hidden or zero-size'], ['width', '0px', 'hidden or zero-size']];+ const missed = [];+ for (const [prop, val, want] of planted) {+ await page.$eval('#btnHelp', (e, p, v) => { e.style.setProperty(p, v, 'important'); if (p === 'width') { e.style.setProperty('padding', '0', 'important'); e.style.setProperty('border', '0', 'important'); e.style.setProperty('min-width', '0', 'important'); e.style.setProperty('overflow', 'hidden', 'important'); } }, prop, val);+ const pr = await headerProbe(page, 'gantt');+ if (!pr.off.some(o => o === '#btnHelp ' + want)) missed.push(`${prop}:${val} -> ${pr.off.join(',') || 'nothing flagged'}`);+ await page.$eval('#btnHelp', e => e.removeAttribute('style')); }- check(`${label} at ${fs * 100}%: readouts whole and every header control on screen at ${W.length} widths 360..2000`, fails.length === 0, `${fails.length} widths fail; ${fails.slice(0, 5).join(' | ')}`);- await page.close();+ await page.$eval('#btnHelp', e => e.setAttribute('style', "font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px"));+ check('the header probe flags a control hidden by display, visibility, opacity or zero width', missed.length === 0, missed.join(' | '));+ }+ if (fs !== 1) { for (let i = 0; i < 4; i++) await page.click('#fsUp'); await settle(page, 200); }+ if (lens !== 'gantt') await view(page, lens);+ const fails = [];+ for (const w of W) {+ await page.setViewport({ width: w, height: 900 });+ await settle(page, 120);+ const p = await headerProbe(page, lens);+ if (p.bad.length || p.off.length) fails.push(`${w}px: ${[...p.bad, ...p.off].join(', ')}`); }+ check(`${label}, ${lens} lens, at ${fs * 100}%: readouts whole and all ${HDR_EXPECT[lens].length} expected header controls present, visible and on screen at ${W.length} widths 360..2000`, fails.length === 0, `${fails.length} widths fail; ${fails.slice(0, 5).join(' | ')}`);+ await page.close(); } } // ── Control-coverage manifest ──-// Every control the app renders in the states below. safe: the gate clicks it and-// asserts nothing throws. terminal: it changes the plan, leaves the page or opens a-// file chooser, so it is presence-checked here and its effect is proven by the named flow.+// Every control the app renders in the states below.+// safe: the gate clicks it (in some state, at least once, or the gate fails naming+// it) and asserts nothing throws.+// terminal: it changes the plan, leaves the page or opens a file chooser, so it is+// presence-checked here and its effect is proven by the flow named in `by`,+// which must be a flow (section) that actually ran in this suite.+// untested: '<reason>': declared so it is not an orphan, but no flow proves its effect.+// Counted and printed in the summary, so the gap is visible.+// A FAMILY entry (a class shared by several controls) is pinned: `key` reads a control's+// identity (a data attribute, its text) and `pin` lists the members expected, so a new+// member of the family is an orphan until it is declared. `within` scopes an entry to+// an ancestor id. const MANIFEST = [- { sel: '.vt-btn', label: 'lens switch', safe: true },+ { sel: '.vt-btn', label: 'lens switch', safe: true, key: c => c.ds.view, pin: ['gantt', 'table', 'analytics'] }, { sel: '#startBtn', label: 'start date', safe: true },- { sel: '#grainSel', label: 'grain picker', safe: false, terminal: true, by: 'dates: every grain' },+ { sel: '#grainSel', label: 'grain picker', terminal: true, by: 'dates' }, { sel: '#btnFit', label: 'Fit', safe: true }, { sel: '#btnDeps', label: 'dependency mode', safe: true, twice: true }, { sel: '#btnAuto', label: 'Auto-schedule', terminal: true, by: 'live: auto-schedule' },@@ -819,68 +995,172 @@ const MANIFEST = [ { sel: '#btnCollapseAll', label: 'Collapse / expand all', safe: true, twice: true }, { sel: '.lane-toggle', label: 'lane chevron', safe: true, twice: true }, { sel: '.phase-chip', label: 'phase legend chip', safe: true, twice: true },- { sel: '.legend-item', label: 'milestone / deps / today legend toggles', safe: true, twice: true },- { sel: '.dp-nav', label: 'date picker year step', safe: true, required: true },- { sel: '.dp-cell', label: 'date picker month', terminal: true, by: 'start picker' },- { sel: '.dp-link', label: 'date picker Revert / Today', terminal: true, by: 'start picker (Revert covered by undo path; Today is 0.56.0 behaviour)' },- { sel: '.dd-item', label: 'menu items', terminal: true, by: 'text size (snapshot) uses export/html; import items open a file chooser' },- { sel: '.sec-sw', label: 'section colour', terminal: true, by: '0.56.0 behaviour, unchanged' },- { sel: '.sec-btn', label: 'section row actions', terminal: true, by: '0.56.0 behaviour, unchanged' },- { sel: '#secNew', label: 'new section name', safe: false, terminal: true, by: '0.56.0 behaviour, unchanged' },- { sel: '#secAdd', label: 'add section', terminal: true, by: '0.56.0 behaviour, unchanged' },- { sel: '#secDone', label: 'close Sections', terminal: true, by: 'coverage state reset' },- { sel: '#ncType', label: 'new: type', terminal: true, by: 'live: create' },- { sel: '#ncPhase', label: 'new: section', terminal: true, by: 'live: create' },+ { sel: '.legend-item', label: 'milestone / deps / today legend toggles, and the inert holiday key', safe: true, twice: true,+ key: c => c.cls.includes('static') ? 'holiday-key' : c.text.split(/\s+/)[0].toLowerCase(), pin: ['holiday-key', 'milestone', 'dependencies', 'today'] },+ { sel: '.dp-nav', label: 'date picker year step', safe: true, key: c => c.ds.nav, pin: ['-1', '1'] },+ { sel: '.dp-cell', label: 'date picker month', terminal: true, by: 'start picker', key: c => 'm' + c.ds.month, pin: Array.from({ length: 12 }, (_, i) => 'm' + i) },+ { sel: '.dp-link', label: 'date picker Revert / Today', terminal: true, by: 'start picker', key: c => c.ds.act, pin: ['revert', 'today'] },+ { sel: '.dd-item', within: 'exportMenu', label: 'export formats', terminal: true, by: 'export menu', key: c => c.ds.fmt, pin: ['json', 'csv', 'svg', 'html'] },+ { sel: '.dd-item', within: 'exportMenu', label: 'help rows (inert text)', safe: true, key: c => (!c.ds.fmt && c.cursor === 'default') ? 'help-text' : undefined, pin: ['help-text'] },+ { sel: '.dd-item', within: 'importMenu', label: 'import JSON / CSV', terminal: true, by: 'import menu', key: c => c.ds.fmt, pin: ['json', 'csv'] },+ { sel: '.dd-item', within: 'importMenu', label: 'import MS Project XML / Jira CSV', untested: 'the import flow checks the file chooser opens for these with the right accept filter, but no MS Project or Jira file is parsed', key: c => c.ds.fmt, pin: ['msproject-xml', 'jira-csv'] },+ { sel: '.sec-sw', label: 'section colour', terminal: true, by: 'sections', key: c => c.title, pin: ['Colour'] },+ { sel: '.sec-btn', label: 'section row actions', terminal: true, by: 'sections', key: c => c.ds.a, pin: ['up', 'down', 'edit', 'del'] },+ { sel: '#secNew', label: 'new section name', terminal: true, by: 'sections' },+ { sel: '#secAdd', label: 'add section', terminal: true, by: 'sections' },+ { sel: '#secDone', label: 'close Sections', safe: true },+ { sel: '#ncType', label: 'new: type', terminal: true, by: 'new item fields' },+ { sel: '#ncPhase', label: 'new: section', terminal: true, by: 'new item fields' }, { sel: '#ncName', label: 'new: name', terminal: true, by: 'live: create' },- { sel: '#ncOwner', label: 'new: owner', terminal: true, by: 'live: create' },- { sel: '#ncStatus', label: 'new: status', terminal: true, by: 'live: create' },+ { sel: '#ncOwner', label: 'new: owner', terminal: true, by: 'new item fields' },+ { sel: '#ncStatus', label: 'new: status', terminal: true, by: 'new item fields' }, { sel: '#ncStart', label: 'new: start', terminal: true, by: 'live: create' }, { sel: '#ncEnd', label: 'new: deadline', terminal: true, by: 'live: create' },- { sel: '#ncDesc', label: 'new: description', terminal: true, by: 'live: create' },- { sel: '#ncProv', label: 'new: provenance', terminal: true, by: 'live: create' },+ { sel: '#ncDesc', label: 'new: description', terminal: true, by: 'new item fields' },+ { sel: '#ncProv', label: 'new: provenance', terminal: true, by: 'new item fields' }, { sel: '#ncCreate', label: 'new: create', terminal: true, by: 'live: create' },- { sel: '#ncCancel', label: 'new: cancel', terminal: true, by: 'coverage state reset' },- { sel: '#tblOwner', label: 'table owner filter', terminal: true, by: '0.56.0 behaviour, unchanged' },- { sel: '#tblStatus', label: 'table status filter', terminal: true, by: '0.56.0 behaviour, unchanged' },- { sel: '#tblSearch', label: 'table search', terminal: true, by: '0.56.0 behaviour, unchanged' },- { sel: '#toPlanOrder', label: 'table: back to plan order', safe: true, required: false },+ { sel: '#ncCancel', label: 'new: cancel', safe: true },+ { sel: '#tblOwner', label: 'table owner filter', terminal: true, by: 'table filters' },+ { sel: '#tblStatus', label: 'table status filter', terminal: true, by: 'table filters' },+ { sel: '#tblSearch', label: 'table search', terminal: true, by: 'table filters' },+ { sel: '#toPlanOrder', label: 'table: back to plan order', safe: true },+ // The bar editor (click a bar).+ { sel: '#deStart', label: 'bar editor: start', terminal: true, by: 'editor: bar' },+ { sel: '#deEnd', label: 'bar editor: end', terminal: true, by: 'editor: bar' },+ { sel: '#deNote', label: 'bar editor: note', terminal: true, by: 'editor: bar' },+ { sel: '#deAddDep', label: 'bar editor: add dependency', terminal: true, by: 'editor: bar' },+ { sel: '.dep-rm', label: 'bar editor: remove dependency', terminal: true, by: 'editor: bar' },+ { sel: '#deApply', label: 'bar editor: Apply', terminal: true, by: 'editor: bar' },+ { sel: '#deClose', label: 'bar editor: Cancel', safe: true },+ { sel: '#deDelete', label: 'bar editor: Delete', terminal: true, by: 'editor: bar' },+ // The milestone editor (click a milestone).+ { sel: '#meName', label: 'milestone editor: name', terminal: true, by: 'editor: milestone' },+ { sel: '#meDate', label: 'milestone editor: date', terminal: true, by: 'editor: milestone' },+ { sel: '#meDesc', label: 'milestone editor: description', terminal: true, by: 'editor: milestone' },+ { sel: '#meApply', label: 'milestone editor: Apply', terminal: true, by: 'editor: milestone' },+ { sel: '#meClose', label: 'milestone editor: Cancel', safe: true },+ { sel: '#meDelete', label: 'milestone editor: Delete', terminal: true, by: 'editor: milestone' },+ // The full item editor (hover a row label, click its pencil).+ { sel: '.row-edit', label: 'row label: Edit all fields (hover only)', safe: true },+ { sel: '#imType', label: 'item editor: type', untested: 'switching a task to a milestone in the full editor is not driven' },+ { sel: '#imPhase', label: 'item editor: section', terminal: true, by: 'editor: item' },+ { sel: '#imName', label: 'item editor: name', terminal: true, by: 'editor: item' },+ { sel: '#imOwner', label: 'item editor: owner', terminal: true, by: 'editor: item' },+ { sel: '#imStatus', label: 'item editor: status', terminal: true, by: 'editor: item' },+ { sel: '#imStart', label: 'item editor: start', terminal: true, by: 'editor: item' },+ { sel: '#imEnd', label: 'item editor: end', terminal: true, by: 'editor: item' },+ { sel: '#imDesc', label: 'item editor: description', terminal: true, by: 'editor: item' },+ { sel: '#imProv', label: 'item editor: provenance', terminal: true, by: 'editor: item' },+ { sel: '#imApply', label: 'item editor: Apply', terminal: true, by: 'editor: item' },+ { sel: '#imCancel', label: 'item editor: Cancel', safe: true },+ { sel: '#imDelete', label: 'item editor: Delete', terminal: true, by: 'editor: item' },+ // The batch panel (Ctrl+click two bars).+ { sel: '#bStart', label: 'batch: earliest start', untested: 'the batch flow drives shift-all only; moving the earliest start is not driven' },+ { sel: '#bEnd', label: 'batch: latest end', untested: 'the batch flow drives shift-all only; moving the latest end is not driven' },+ { sel: '#bShiftAll', label: 'batch: shift all', terminal: true, by: 'editor: batch' },+ { sel: '#bShiftS', label: 'batch: shift start', terminal: true, by: 'editor: batch' },+ { sel: '#bShiftE', label: 'batch: shift end', terminal: true, by: 'editor: batch' },+ { sel: '#bApply', label: 'batch: Apply', terminal: true, by: 'editor: batch' },+ { sel: '#bClose', label: 'batch: Cancel', safe: true },+ { sel: '#bDelete', label: 'batch: Delete', terminal: true, by: 'editor: batch' },+ { sel: '#beCopyIds', label: 'batch: Copy IDs', untested: 'writes the system clipboard, which headless Chrome does not expose to the suite' },+ { sel: '#selCount', label: 'selection count chip (Copy IDs)', untested: 'writes the system clipboard, which headless Chrome does not expose to the suite' },+ { sel: '#btnCopyIds', label: 'table: Copy IDs', required: false, untested: 'writes the system clipboard, which headless Chrome does not expose to the suite' },+ // The table's hover-only delete.+ { sel: '.row-del', label: 'table row delete (hover only)', terminal: true, by: 'live: delete' }, ];-function ctrlMatches(c, sel) {- if (sel.startsWith('#')) return c.id === sel.slice(1);- if (sel.startsWith('.')) return c.cls.split(/\s+/).includes(sel.slice(1));- return c.tag === sel;+function ctrlMatches(c, d) {+ let ok;+ if (d.sel.startsWith('#')) ok = c.id === d.sel.slice(1);+ else if (d.sel.startsWith('.')) ok = c.cls.split(/\s+/).includes(d.sel.slice(1));+ else ok = c.tag === d.sel;+ if (!ok) return false;+ if (d.within && !c.anc.includes(d.within)) return false;+ if (d.pin && !d.pin.includes(d.key(c))) return false;+ return true; }+// A family member whose key is not pinned matches nothing, so it is reported as an+// orphan with its key, which is exactly what has to be added.+const covKey = (d, c) => d.pin ? `${d.sel}${d.within ? '@' + d.within : ''}=${d.key(c)}` : d.sel + (d.within ? '@' + d.within : '');+const entryId = d => d.sel + (d.within ? '@' + d.within : '') + (d.pin ? '[' + d.pin.join(',') + ']' : ''); // Most specific entry wins: an id beats a class, so #btnCollapseAll is not read as a // plain .legend-item and a phase chip is not read as the generic toggle. function manifestFor(c) {- return MANIFEST.find(d => d.sel.startsWith('#') && ctrlMatches(c, d.sel))- || MANIFEST.find(d => d.sel === '.phase-chip' && ctrlMatches(c, d.sel))- || MANIFEST.find(d => ctrlMatches(c, d.sel));+ return MANIFEST.find(d => d.sel.startsWith('#') && ctrlMatches(c, d))+ || MANIFEST.find(d => d.sel === '.phase-chip' && ctrlMatches(c, d))+ || (c.id ? null : MANIFEST.find(d => ctrlMatches(c, d))); } async function liveControls(page) { return page.evaluate(() => {- const vis = e => {- if (e.checkVisibility && !e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, opacityProperty: true, visibilityProperty: true })) return false;- const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0;- };- // .legend-item and .dd-item are divs with listeners, not buttons, and are real- // controls all the same, so they are enumerated too.- return [...document.querySelectorAll('button,[onclick],select,textarea,input:not([type=hidden]),[role="button"],.legend-item,.dd-item')]- .filter(vis).filter(e => !e.classList.contains('static'))- .map((e, i) => { e.dataset.cov = String(i); return { i, tag: e.tagName.toLowerCase(), id: e.id || '', cls: typeof e.className === 'string' ? e.className : '', onclick: e.getAttribute('onclick') || '', text: (e.textContent || '').trim().slice(0, 30) }; })+ // .legend-item, .dd-item, .dep-rm and the selection chip are divs or spans with+ // listeners, not buttons, and are real controls all the same, so they are+ // enumerated too. Nothing is skipped by class: an inert element is declared as such.+ // Clear the numbering from the previous call first: a control hidden since then keeps+ // its old data-cov, and a lookup by number would find it (no box) instead of the+ // live one that now carries that number.+ document.querySelectorAll('[data-cov]').forEach(e => e.removeAttribute('data-cov'));+ return [...document.querySelectorAll('button,[onclick],select,textarea,input:not([type=hidden]),[role="button"],.legend-item,.dd-item,.dep-rm,#selCount')]+ .filter(e => window.__uiVis(e))+ .map((e, i) => { e.dataset.cov = String(i);+ const ds = { ...e.dataset }; delete ds.cov;+ const anc = []; for (let a = e.parentElement; a; a = a.parentElement) if (a.id) anc.push(a.id);+ return { i, tag: e.tagName.toLowerCase(), id: e.id || '', cls: typeof e.className === 'string' ? e.className : '', onclick: e.getAttribute('onclick') || '',+ text: (e.textContent || '').trim().slice(0, 30), ds, anc, title: e.title || '', cursor: getComputedStyle(e).cursor }; }) // A signature plus its ordinal among equal signatures is how a control is found // again after the state is reopened; a bare index would drift onto a neighbour.- .map((c, _, all) => ({ ...c, sig: `${c.tag}|${c.id}|${c.cls.replace(/\b(on|off|collapsed|is-all|sel|shifted)\b/g, '').trim()}|${c.text}` }))+ .map(c => ({ ...c, sig: `${c.tag}|${c.id}|${c.cls.replace(/\b(off-default|on|off|collapsed|is-all|sel|shifted|muted)\b/g, '').trim()}|${c.text.replace(/\d+%/g, 'N%')}` })) .map((c, i, all) => ({ ...c, nth: all.slice(0, i).filter(x => x.sig === c.sig).length })); }); }+// Bring a bar into view and click its middle, as a person would.+// Short tasks are a few px wide at the week grain, all drag handle, so the bar is+// clicked at the day grain, where its middle is the bar's own body.+async function clickBar(page, id, mods = []) {+ if (await page.$eval('#grainSel', s => s.value) !== 'day') await setGrain(page, 'day');+ const p = await page.evaluate(i => { const b = document.querySelector(`.bar[data-id="${i}"]`); b.scrollIntoView({ block: 'center', inline: 'center' });+ const r = b.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, id);+ await settle(page, 100);+ const q = await page.evaluate(i => { const r = document.querySelector(`.bar[data-id="${i}"]`).getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, id);+ for (const m of mods) await page.keyboard.down(m);+ await page.mouse.click(q.x, q.y);+ for (const m of mods) await page.keyboard.up(m);+ await settle(page, 150);+}+async function clickMilestone(page, uid) {+ await page.evaluate(u => document.querySelector(`.ms-hit[data-msuid="${u}"]`).scrollIntoView({ block: 'center', inline: 'center' }), uid); await settle(page, 100);+ const q = await page.evaluate(u => { const r = document.querySelector(`.ms-hit[data-msuid="${u}"]`).getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, uid);+ await page.mouse.click(q.x, q.y); await settle(page, 150);+}+// Hover a row label (the pencil is opacity 0 until then) and click the pencil.+async function openItemEditor(page, name) {+ const found = await page.evaluate(n => { const e = [...document.querySelectorAll('.label-row:not(.group)')].find(x => x.textContent.includes(n));+ if (!e) return 'no label row'; e.dataset.covrow = '1'; e.scrollIntoView({ block: 'center' }); const r = e.getBoundingClientRect(); return r.height > 0 ? '' : `row has no box (${e.className})`; }, name);+ if (found) throw new Error(`openItemEditor(${name}): ${found}`);+ await settle(page, 100);+ await page.hover('.label-row[data-covrow="1"]'); await settle(page, 200);+ const shown = await page.$eval('.label-row[data-covrow="1"] .row-edit', b => window.__uiVis(b));+ await page.click('.label-row[data-covrow="1"] .row-edit'); await settle(page, 200);+ await page.evaluate(() => document.querySelector('.label-row[data-covrow="1"]')?.removeAttribute('data-covrow'));+ return shown;+}+async function setVal(page, sel, v) {+ await page.$eval(sel, (e, v) => { e.value = v; e.dispatchEvent(new Event('input', { bubbles: true })); e.dispatchEvent(new Event('change', { bubbles: true })); }, v);+}+const editorShown = page => page.$eval('#editor', e => e.classList.contains('show') && window.__uiVis(e));+// Escape steps back one rung at a time (field, row, selection); three clear everything.+async function clearSel(page) { await page.mouse.click(60, 22); for (let i = 0; i < 3; i++) await page.keyboard.press('Escape'); await settle(page, 100); }+async function undo(page) { await page.keyboard.down('Control'); await page.keyboard.press('KeyZ'); await page.keyboard.up('Control'); await settle(page, 250); }+ async function coverageGate(browser, url) { const page = await openPage(browser, url); const reset = async () => { await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); // an outside click is how the menus and the date picker close for a person too await page.mouse.click(60, 22); await settle(page, 120);+ await page.mouse.move(700, 5); };+ const plan0 = await exportPlan(url);+ const kick = plan0.items.find(r => r.name === 'Kickoff'); const STATES = [ ['chart', async () => { await view(page, 'gantt'); }], ['date picker', async () => { await view(page, 'gantt'); await page.click('#startBtn'); }],@@ -889,32 +1169,57 @@ async function coverageGate(browser, url) { ['help menu', async () => { await page.click('#btnHelp'); }], ['sections', async () => { await page.click('#btnSections'); }], ['new item', async () => { await page.click('#btnNew'); }],+ ['bar editor', async () => { await view(page, 'gantt'); await clickBar(page, 'finale'); }],+ ['milestone editor', async () => { await view(page, 'gantt'); await clickMilestone(page, kick.uid); }],+ ['chart row hover', async () => { await view(page, 'gantt');+ await page.evaluate(() => document.querySelectorAll('.label-row:not(.group)')[2].scrollIntoView({ block: 'center' }));+ const r = await page.evaluate(() => { const b = document.querySelectorAll('.label-row:not(.group)')[2].getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });+ await page.mouse.move(r.x, r.y); }],+ ['item editor', async () => { await view(page, 'gantt'); await openItemEditor(page, 'Lane 1 task 1'); }],+ ['batch panel', async () => { await view(page, 'gantt'); await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']); }], ['table', async () => { await view(page, 'table'); }],+ ['table row hover', async () => { await view(page, 'table');+ const r = await page.evaluate(() => { const tr = document.querySelectorAll('#tableView tbody tr[data-key]')[3]; tr.scrollIntoView({ block: 'center' }); const b = tr.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; });+ await page.mouse.move(r.x, r.y); }], ['table sorted', async () => { await view(page, 'table'); await page.evaluate(() => document.querySelector('#tableView thead th[data-sort]')?.click()); }], ['analytics', async () => { await view(page, 'analytics'); }], ];- const seen = new Set(), orphans = [];+ const seen = new Set(), orphans = [], stateControls = {};+ const openFailed = [];+ const tryOpen = async (name, open) => { try { await open(); return true; } catch (e) { openFailed.push(`${name}: ${e.message.slice(0, 80)}`); await reset(); return false; } }; for (const [name, open] of STATES) {- await open(); await settle(page, 200);- for (const c of await liveControls(page)) {+ if (!await tryOpen(name, open)) continue;+ await settle(page, 200);+ const live = await liveControls(page);+ stateControls[name] = live.length;+ for (const c of live) { const hit = manifestFor(c);- if (hit) seen.add(hit.sel);- else orphans.push(`[${name}] <${c.tag} id="${c.id}" class="${c.cls.slice(0, 40)}"> "${c.text}"`);+ if (hit) { seen.add(entryId(hit)); seen.add(covKey(hit, c)); }+ else orphans.push(`[${name}] <${c.tag} id="${c.id}" class="${c.cls.slice(0, 40)}" data=${JSON.stringify(c.ds).slice(0, 40)}> "${c.text}"`); } await reset();+ // A state that renders nothing new would make every check about it vacuous. }+ // The added states must really have opened what they claim to.+ for (const [st, sel] of [['bar editor', '#deApply'], ['milestone editor', '#meApply'], ['item editor', '#imApply'], ['batch panel', '#bApply'], ['table row hover', '.row-del'], ['chart row hover', '.row-edit'], ['table sorted', '#toPlanOrder']])+ check(`state "${st}" opened (its ${sel} rendered)`, seen.has(entryId(MANIFEST.find(d => d.sel === sel))), `controls seen in that state: ${stateControls[st]}`); await view(page, 'gantt'); check('every rendered control is declared (no orphans)', orphans.length === 0, [...new Set(orphans)].slice(0, 8).join(' | '));- const missing = MANIFEST.filter(d => d.required !== false && !seen.has(d.sel)).map(d => d.sel);- check('every required control rendered in some state (no fiction)', missing.length === 0, missing.join(', '));- check('every terminal control names the flow that proves it', MANIFEST.filter(d => d.terminal).every(d => d.by));+ const missing = [];+ for (const d of MANIFEST) {+ if (d.required === false) continue;+ if (!seen.has(entryId(d))) { missing.push(entryId(d)); continue; }+ if (d.pin) for (const k of d.pin) { const ck = `${d.sel}${d.within ? '@' + d.within : ''}=${k}`; if (!seen.has(ck)) missing.push(ck); }+ }+ check('every required control (and every pinned family member) rendered in some state (no fiction)', missing.length === 0, missing.join(', ')); // Click every safe control, in the state where it lives, and fail on any page error. const before = pageErrors.length; const walkBefore = spanOf(await savedPlan(url, { minWait: 200 }));- let clicked = 0, covered = 0, skipped = [];+ let clicked = 0, covered = 0; const skipped = [], clickedKeys = new Set(); for (const [name, open] of STATES) {- await open(); await settle(page, 150);+ if (!await tryOpen(name, open)) continue;+ await settle(page, 150); const ctrls = await liveControls(page); for (const c of ctrls) { const d = manifestFor(c);@@ -924,34 +1229,360 @@ async function coverageGate(browser, url) { const fresh = (await liveControls(page)).find(x => x.sig === c.sig && x.nth === c.nth); const h = fresh && await page.$(`[data-cov="${fresh.i}"]`); const box = h && await h.boundingBox();- if (!box) { skipped.push(`${name}:${d.sel}`); continue; }+ if (!box) { skipped.push(`${name}:${covKey(d, c)} (no box)`); continue; } // A mouse click lands on whatever is on top at that point. In an overlay state // (the date picker hangs over the legend) a chip under the overlay is not // clickable by a person either, and "clicking" it would really click the- // overlay's month cell and move the plan. It was clicked in the chart state.+ // overlay's month cell and move the plan. It must be clicked in another state;+ // the per-entry check below fails if it never is. const onTop = await h.evaluate(e => { const r = e.getBoundingClientRect(); const t = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return !!t && (t === e || e.contains(t)); });- if (!onTop) { covered++; continue; }+ if (!onTop) { covered++; skipped.push(`${name}:${covKey(d, c)} (covered)`); continue; } try { if (process.env.COV_DEBUG) console.log(' click', name, c.sig, c.nth);- await h.click(); clicked++;- if (d.twice) { await settle(page, 60); const again = (await liveControls(page)).find(x => x.sig === c.sig && x.nth === c.nth);- const h2 = again && await page.$(`[data-cov="${again.i}"]`); if (h2 && await h2.boundingBox()) await h2.click(); }- } catch (e) { skipped.push(`${name}:${d.sel} (${e.message.slice(0, 40)})`); }+ await h.click(); clicked++; clickedKeys.add(entryId(d)); clickedKeys.add(covKey(d, c));+ // The second click of a toggle goes to the same element when it is still in the+ // page. Its text may have changed ("Collapse all" -> "Expand all"), so a lookup+ // by signature would miss it and silently leave the toggle flipped.+ if (d.twice) { await settle(page, 60);+ let h2 = (await h.evaluate(e => e.isConnected)) ? h : null;+ if (!h2) { const bare = x => x.sig.split('|').slice(0, 3).join('|'); const again = (await liveControls(page)).find(x => bare(x) === bare(c) && x.nth === c.nth); h2 = again && await page.$(`[data-cov="${again.i}"]`); }+ if (h2 && await h2.boundingBox()) await h2.click(); else skipped.push(`${name}:${covKey(d, c)} (second click of a toggle found nothing)`); }+ } catch (e) { skipped.push(`${name}:${covKey(d, c)} (${e.message.slice(0, 40)})`); } await settle(page, 60); // A click that opened something (a menu, the modal, a lens) is closed before the // next, so each control is clicked in the state the walk opened, not a stray one.- await reset(); await open(); await settle(page, 60);+ await reset(); if (!await tryOpen(name, open)) break; await settle(page, 60); } await reset(); }+ check('every walk state could be opened', openFailed.length === 0, [...new Set(openFailed)].join(' | ')); // The walk clicks only safe controls, so the plan must come out of it untouched.- const after = spanOf(await exportPlan(url));+ const after = spanOf(await savedPlan(url, { minWait: 200 })); check('the safe-control walk did not change the plan', after.first === walkBefore.first && after.last === walkBefore.last && after.n === walkBefore.n, JSON.stringify({ walkBefore, after }));- console.log(` (${covered} controls sat under an open overlay in some state and were clicked in another)`);- check(`clicked every safe control (${clicked} clicks) with no page error`, pageErrors.length === before && clicked > 40, pageErrors.slice(before, before + 3).join(' | ') + (skipped.length ? ' skipped: ' + skipped.slice(0, 5).join(', ') : ''));+ console.log(` (${covered} control instances sat under an open overlay in some state; ${skipped.length} skipped: ${skipped.slice(0, 4).join(', ')})`);+ check(`clicked safe controls (${clicked} clicks) with no page error`, pageErrors.length === before && clicked > 40, pageErrors.slice(before, before + 3).join(' | '));+ // Every safe entry, and every pinned member of a safe family, was clicked at least once+ // in some state; a skip (no box, covered, click threw) in every state is a failure.+ const neverClicked = [];+ for (const d of MANIFEST.filter(d => d.safe && d.required !== false)) {+ if (!clickedKeys.has(entryId(d))) { neverClicked.push(entryId(d)); continue; }+ if (d.pin) for (const k of d.pin) { const ck = `${d.sel}${d.within ? '@' + d.within : ''}=${k}`; if (!clickedKeys.has(ck)) neverClicked.push(ck); }+ }+ const whyNot = neverClicked.map(k => skipped.filter(x => x.includes(k.split('[')[0])).slice(0, 2).join(' / ')).filter(Boolean);+ check('every safe control was clicked at least once in some state', neverClicked.length === 0, neverClicked.join(', ') + (whyNot.length ? ' | why: ' + whyNot.join(' ; ') : ''));+ await page.close();+}+// Run at the very end of a full run, when every flow has had its chance to run.+function manifestHonesty() {+ const terminal = MANIFEST.filter(d => d.terminal);+ const noBy = terminal.filter(d => !d.by).map(entryId);+ check('every terminal control names the flow that proves it', noBy.length === 0, noBy.join(', '));+ const dangling = terminal.filter(d => d.by && !flowsRan.has(d.by)).map(d => `${entryId(d)} -> "${d.by}"`);+ check('every terminal control\'s `by` names a flow that actually ran', dangling.length === 0, dangling.join(' | ') + ` (flows that ran: ${[...flowsRan].join(', ')})`);+ const bad = MANIFEST.filter(d => !d.safe && !d.terminal && !d.untested).map(entryId);+ check('every manifest entry is safe, terminal or explicitly untested', bad.length === 0, bad.join(', '));+ return MANIFEST.filter(d => d.untested);+}++// ── Flows for the editors, the menus, the table filters and the Sections panel ──+// Each asserts the EFFECT on the plan the server holds (via export/json after the+// debounced save), then undoes it, and checks the plan came back.+const rowOf = (plan, pred) => plan.items.find(pred);+async function editorFlows(browser, S) {+ SRV = S.url;+ const page = await openPage(browser, S.url);+ const plan0 = await savedPlan(S.url, { minWait: 200 });+ const n0 = spanOf(plan0).n;+ const back = async what => { const p = await savedPlan(S.url); check(`${what}: undo puts the plan back`, JSON.stringify(spanOf(p)) === JSON.stringify(spanOf(plan0)), JSON.stringify(spanOf(p))); return p; };++ section = 'editor: bar';+ await clickBar(page, 'finale');+ check('clicking a bar opens its editor', await editorShown(page) && await page.$eval('#deTitle', e => e.textContent.includes('Launch finale')));+ await setVal(page, '#deEnd', '2027-01-20'); await setVal(page, '#deNote', 'late');+ await page.click('#deApply'); await settle(page, 200);+ let p = await savedPlan(S.url); let f = rowOf(p, r => r.id === 'finale');+ check('Apply saves the new end and the note', f.end === '2027-01-20' && f.note === 'late', JSON.stringify({ end: f.end, note: f.note }));+ check('Apply closes the editor', !(await editorShown(page)));+ await undo(page); await back('bar editor Apply');+ await clickBar(page, 'finale');+ await setVal(page, '#deEnd', '2027-02-20');+ await page.click('#deClose'); await settle(page, 200);+ p = await savedPlan(S.url);+ check('Cancel closes the editor and saves nothing', !(await editorShown(page)) && rowOf(p, r => r.id === 'finale').end === LAST);+ await clickBar(page, 'finale');+ await page.select('#deAddDep', 'p0t0'); await settle(page, 200);+ p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');+ check('picking from "+ add dependency" adds it to the saved plan', (f.deps || []).includes('p0t0'), JSON.stringify(f.deps));+ const rmN = await page.$$eval('#deDepList .dep-rm', e => e.length);+ await page.click('#deDepList .dep-rm[data-dep="gate-a"]'); await settle(page, 200);+ p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');+ check('the dependency list listed both, and its remove button drops one', rmN === 2 && !(f.deps || []).includes('gate-a') && (f.deps || []).includes('p0t0'), `${rmN} listed, now ${JSON.stringify(f.deps)}`);+ await page.mouse.click(60, 22); await settle(page, 100);+ await undo(page); await undo(page);+ p = await savedPlan(S.url); f = rowOf(p, r => r.id === 'finale');+ check('two undos restore the dependencies', JSON.stringify(f.deps) === JSON.stringify(['gate-a']), JSON.stringify(f.deps));+ await clickBar(page, 'finale');+ await page.click('#deDelete'); await settle(page, 300);+ p = await savedPlan(S.url);+ check('Delete removes the task', spanOf(p).n === n0 - 1 && !rowOf(p, r => r.id === 'finale'), `${n0} -> ${spanOf(p).n}`);+ await undo(page); await back('bar editor Delete');++ section = 'editor: milestone';+ const kick = rowOf(plan0, r => r.name === 'Kickoff');+ await clickMilestone(page, kick.uid);+ check('clicking a milestone opens its editor', await editorShown(page) && await page.$eval('#meName', e => e.value) === 'Kickoff');+ await setVal(page, '#meName', 'Kickoff moved'); await setVal(page, '#meDate', '2026-09-22'); await setVal(page, '#meDesc', 'moved a day');+ await page.click('#meApply'); await settle(page, 200);+ p = await savedPlan(S.url); let k = rowOf(p, r => r.uid === kick.uid);+ check('Apply saves the name, date and description', k.name === 'Kickoff moved' && k.date === '2026-09-22' && k.description === 'moved a day', JSON.stringify(k));+ await undo(page); p = await back('milestone Apply');+ check('and undo restores the name', rowOf(p, r => r.uid === kick.uid).name === 'Kickoff');+ await clickMilestone(page, kick.uid);+ await setVal(page, '#meDate', '2026-12-01'); await page.click('#meClose'); await settle(page, 200);+ p = await savedPlan(S.url);+ check('Cancel saves nothing', !(await editorShown(page)) && rowOf(p, r => r.uid === kick.uid).date === FIRST);+ await clickMilestone(page, kick.uid);+ await page.click('#meDelete'); await settle(page, 300);+ p = await savedPlan(S.url);+ check('Delete removes the milestone', spanOf(p).n === n0 - 1 && !rowOf(p, r => r.uid === kick.uid));+ await undo(page); await back('milestone Delete');++ section = 'editor: item';+ const t1 = rowOf(plan0, r => r.name === 'Lane 1 task 1');+ check('hovering a row label reveals its pencil', await openItemEditor(page, 'Lane 1 task 1'));+ check('the pencil opens the full editor on that row', await page.$eval('#imName', e => window.__uiVis(e) && e.value) === 'Lane 1 task 1');+ await setVal(page, '#imName', 'Lane 1 task 1 renamed'); await setVal(page, '#imOwner', 'Zed');+ await page.select('#imStatus', 'in-progress'); await page.select('#imPhase', '3');+ await setVal(page, '#imStart', '2026-10-05'); await setVal(page, '#imEnd', '2026-10-09');+ await setVal(page, '#imDesc', 'full edit'); await setVal(page, '#imProv', 'ui-test prov');+ await page.click('#imApply'); await settle(page, 300);+ p = await savedPlan(S.url); let t = rowOf(p, r => r.uid === t1.uid);+ check('Apply saves name, owner, status, section, dates, description and provenance',+ t.name === 'Lane 1 task 1 renamed' && t.owner === 'Zed' && t.status === 'in-progress' && t.phase === 3 && t.start === '2026-10-05' && t.end === '2026-10-09' && t.description === 'full edit' && JSON.stringify(t.provenance || '').includes('ui-test prov'), JSON.stringify(t));+ await undo(page);+ p = await savedPlan(S.url); t = rowOf(p, r => r.uid === t1.uid);+ // This check caught a real bug, inherited from 0.56.0 and fixed in 0.57.1: Apply+ // recorded the field edits for undo and then moved the row to its new section without+ // recording the move, so Ctrl+Z restored every field except the section.+ check('one undo takes the whole full-editor apply back (section included)', t.name === 'Lane 1 task 1' && t.owner === t1.owner && t.phase === 0 && t.start === t1.start, JSON.stringify(t));+ if (t.phase !== 0) { // put it back by hand so the flows after this one start from the seeded plan+ await openItemEditor(page, 'Lane 1 task 1'); await page.select('#imPhase', '0'); await page.click('#imApply'); await settle(page, 300);+ }+ await openItemEditor(page, 'Lane 1 task 1');+ await setVal(page, '#imName', 'should not stick'); await page.click('#imCancel'); await settle(page, 200);+ p = await savedPlan(S.url);+ check('Cancel saves nothing', rowOf(p, r => r.uid === t1.uid).name === 'Lane 1 task 1');+ await openItemEditor(page, 'Lane 1 task 1');+ await page.click('#imDelete'); await settle(page, 300);+ p = await savedPlan(S.url);+ check('Delete removes the row', spanOf(p).n === n0 - 1 && !rowOf(p, r => r.uid === t1.uid));+ await undo(page); await back('full editor Delete');++ section = 'editor: batch';+ await clearSel(page);+ await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']);+ check('Ctrl+clicking two bars opens the batch panel for both', await editorShown(page) && /2 tasks selected/.test(await page.$eval('#editor', e => e.textContent)));+ const a0 = rowOf(plan0, r => r.id === 'p0t0'), b0 = rowOf(plan0, r => r.id === 'p0t1');+ await setVal(page, '#bShiftAll', '3'); await setVal(page, '#bShiftS', '1'); await setVal(page, '#bShiftE', '2');+ await page.click('#bApply'); await settle(page, 300);+ p = await savedPlan(S.url);+ const a1 = rowOf(p, r => r.id === 'p0t0'), b1 = rowOf(p, r => r.id === 'p0t1');+ check('batch Apply shifts every selected start by all+start (4) and end by all+end (5)',+ a1.start === addDaysISO(a0.start, 4) && a1.end === addDaysISO(a0.end, 5) && b1.start === addDaysISO(b0.start, 4) && b1.end === addDaysISO(b0.end, 5), JSON.stringify({ a0, a1 }));+ await undo(page); p = await back('batch Apply');+ // The span check above cannot see a row that stayed shifted inside the plan's range,+ // and until 0.57.1 one did: Apply pushed an undo entry per task, so one Ctrl+Z put a+ // single row back. Both rows are read here.+ check('one undo takes the whole batch Apply back (every selected row)',+ rowOf(p, r => r.id === 'p0t0').start === a0.start && rowOf(p, r => r.id === 'p0t0').end === a0.end && rowOf(p, r => r.id === 'p0t1').start === b0.start && rowOf(p, r => r.id === 'p0t1').end === b0.end,+ JSON.stringify({ a: rowOf(p, r => r.id === 'p0t0'), b: rowOf(p, r => r.id === 'p0t1') }));+ await clearSel(page);+ await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']);+ await setVal(page, '#bShiftAll', '9'); await page.click('#bClose'); await settle(page, 200);+ p = await savedPlan(S.url);+ check('batch Cancel saves nothing', rowOf(p, r => r.id === 'p0t0').start === a0.start, JSON.stringify({ want: a0.start, got: rowOf(p, r => r.id === 'p0t0') }));+ await clearSel(page);+ await clickBar(page, 'p0t0', ['Control']); await clickBar(page, 'p0t1', ['Control']);+ await page.click('#bDelete'); await settle(page, 300);+ p = await savedPlan(S.url);+ check('batch Delete removes both rows', spanOf(p).n === n0 - 2 && !rowOf(p, r => r.id === 'p0t0') && !rowOf(p, r => r.id === 'p0t1'), `${n0} -> ${spanOf(p).n}`);+ await undo(page); await back('batch Delete');++ section = 'new item fields';+ await page.mouse.click(60, 22); await settle(page, 100);+ await page.click('#btnNew'); await settle(page, 150);+ await page.select('#ncType', 'task'); await page.select('#ncPhase', '4');+ await page.type('#ncName', 'Fielded task'); await setVal(page, '#ncOwner', 'Quinn');+ await page.select('#ncStatus', 'blocked'); await setVal(page, '#ncStart', '2026-11-02'); await setVal(page, '#ncEnd', '2026-11-06');+ await setVal(page, '#ncDesc', 'new desc'); await setVal(page, '#ncProv', 'typed-prov');+ await page.click('#ncCreate'); await settle(page, 800);+ p = await savedPlan(S.url); t = rowOf(p, r => r.name === 'Fielded task');+ check('New saves type, section, owner, status, dates, description and provenance', !!t && t.type === 'task' && t.phase === 4 && t.owner === 'Quinn' && t.status === 'blocked' && t.start === '2026-11-02' && t.end === '2026-11-06' && t.description === 'new desc' && JSON.stringify(t.provenance || '').includes('typed-prov'), JSON.stringify(t));+ await undo(page); await back('create with every field');+ await page.click('#btnNew'); await settle(page, 150);+ await page.select('#ncType', 'milestone'); await settle(page, 100);+ await page.type('#ncName', 'Fielded gate'); await setVal(page, '#ncEnd', '2026-11-20');+ await page.click('#ncCreate'); await settle(page, 800);+ p = await savedPlan(S.url); t = rowOf(p, r => r.name === 'Fielded gate');+ check('type = milestone creates a milestone on the chosen date', !!t && t.type === 'milestone' && t.date === '2026-11-20' && !t.start, JSON.stringify(t));+ await undo(page); await back('create a milestone');+ await page.click('#btnNew'); await settle(page, 150);+ await page.type('#ncName', 'Never made'); await page.click('#ncCancel'); await settle(page, 300);+ p = await savedPlan(S.url);+ check('New then Cancel creates nothing', !rowOf(p, r => r.name === 'Never made') && spanOf(p).n === n0);++ section = 'table filters';+ await view(page, 'table');+ const tableRows = () => page.$$eval('#tableView tbody tr[data-key]', trs => trs.map(tr => tr.textContent));+ const all = await tableRows();+ const johns = plan0.items.filter(r => r.owner === 'John').length;+ await page.select('#tblOwner', 'John'); await settle(page, 200);+ let rows = await tableRows();+ check(`owner filter John shows exactly his ${johns} rows`, rows.length === johns && rows.every(x => x.includes('John')), `${rows.length} rows`);+ await page.select('#tblOwner', ''); await settle(page, 200);+ check('owner filter back to everyone', (await tableRows()).length === all.length);+ const cancelled = plan0.items.filter(r => r.status === 'cancelled').length;+ await page.select('#tblStatus', 'cancelled'); await settle(page, 200);+ rows = await tableRows();+ check(`status filter "cancelled" shows the ${cancelled} cancelled row`, rows.length === cancelled && rows[0]?.includes('Dropped export'), `${rows.length} rows`);+ await page.select('#tblStatus', ''); await settle(page, 200);+ await page.type('#tblSearch', 'Lane 13 '); await settle(page, 300);+ rows = await tableRows();+ check('search "Lane 13 " shows only lane 13\'s 6 rows', rows.length === 6 && rows.every(x => x.includes('Lane 13 ')), `${rows.length} rows`);+ await page.$eval('#tblSearch', e => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await settle(page, 200);+ check('clearing the search shows every row again', (await tableRows()).length === all.length);+ // Sort, then Back to plan order.+ await page.evaluate(() => [...document.querySelectorAll('#tableView thead th[data-sort]')].find(th => /owner/i.test(th.textContent)).click()); await settle(page, 200);+ const sorted = await tableRows();+ check('sorting by owner reorders the rows and offers "Back to plan order"', sorted.join() !== all.join() && await page.$eval('#toPlanOrder', b => window.__uiVis(b)));+ await page.click('#toPlanOrder'); await settle(page, 200);+ // The table opens sorted by deadline (the app's default), so "plan order" is the+ // order the rows have in the plan itself, read from the server, not the first view.+ const restored = await tableRows();+ const planOrder = (await savedPlan(S.url, { minWait: 200 })).items.filter(r => r.type !== 'group').map(r => r.name);+ const firstDiff = restored.findIndex((x, i) => !x.includes(planOrder[i]));+ check('"Back to plan order" puts the rows in the plan\'s own order and hides itself', firstDiff === -1 && restored.length === planOrder.length && !(await page.$('#toPlanOrder').then(b => b && b.evaluate(e => window.__uiVis(e)))),+ `first difference at row ${firstDiff}: "${(restored[firstDiff] || '').slice(0, 50)}" vs "${planOrder[firstDiff]}"`);+ await view(page, 'gantt'); await page.close(); } +async function menuFlows(browser, S) {+ section = 'export menu';+ const page = await openPage(browser, S.url);+ const dl = join(TMP, 'downloads'); mkdirSync(dl, { recursive: true });+ const cdp = await page.createCDPSession();+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dl });+ const got = {};+ for (const fmt of ['json', 'csv', 'svg', 'html']) {+ const before = new Set(readdirSync(dl));+ await page.click('#btnExport'); await settle(page, 100);+ await page.click(`#exportMenu .dd-item[data-fmt="${fmt}"]`);+ let f = null;+ for (let i = 0; i < 50 && !f; i++) { await sleep(100); f = readdirSync(dl).find(x => !before.has(x) && !x.endsWith('.crdownload')); }+ got[fmt] = f ? readFileSync(join(dl, f), 'utf8') : '';+ check(`Export ${fmt} downloads a file`, !!f, 'no download appeared');+ }+ const n = spanOf(await exportPlan(S.url)).n;+ let parsed = null; try { parsed = JSON.parse(got.json); } catch {}+ check('the JSON export is the plan (same number of rows as the server holds)', !!parsed && spanOf(parsed).n === n, parsed ? String(spanOf(parsed).n) : 'unparseable');+ check('the CSV export has a header and a line per row', got.csv.trim().split('\n').length >= n + 1 && /name/i.test(got.csv.split('\n')[0]), got.csv.split('\n')[0].slice(0, 80));+ check('the SVG export is an SVG with the plan in it', /<svg[\s>]/.test(got.svg) && got.svg.includes('Launch finale'));+ check('the HTML export is a self-contained snapshot', got.html.includes('const SNAPSHOT = true;'));+ await page.close();++ // Import on a server of its own, so the main plan is not replaced.+ section = 'import menu';+ const small = { project: { title: 'Import Target', appName: 'import-target' }, phases: [{ name: 'Only', start: '2026-09', end: '2026-12' }],+ items: [{ type: 'task', id: 'x', name: 'Before import', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' }] };+ const I = await startServer(small, 'import');+ const ip = await openPage(browser, I.url);+ // The two formats whose parse is not driven: the chooser must still open, filtered right.+ for (const [fmt, accept] of [['msproject-xml', '.xml'], ['jira-csv', '.csv']]) {+ await ip.click('#btnImport'); await settle(ip, 100);+ const [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }).catch(() => null), ip.click(`#importMenu .dd-item[data-fmt="${fmt}"]`)]);+ check(`Import ${fmt} opens a file chooser for ${accept}`, !!ch && (await ip.$eval('#importFile', e => e.accept)) === accept);+ if (ch) await ch.cancel();+ }+ const jsonFile = join(TMP, 'import.json'); writeFileSync(jsonFile, got.json);+ await ip.click('#btnImport'); await settle(ip, 100);+ let [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="json"]')]);+ await Promise.all([ip.waitForNavigation({ waitUntil: 'networkidle0', timeout: 10000 }).catch(() => null), ch.accept([jsonFile])]);+ await settle(ip, 300);+ let ipl = await exportPlan(I.url);+ check('Import JSON (the export of the main plan) replaces the plan with it', spanOf(ipl).n === n && !!rowOf(ipl, r => r.name === 'Launch finale') && !rowOf(ipl, r => r.name === 'Before import'), `${spanOf(ipl).n} rows`);+ check('and the page reloaded onto the imported plan', await ip.evaluate(() => [...document.querySelectorAll('.label-row')].some(e => e.textContent.includes('Launch finale'))));+ // CSV: import the small plan's own CSV export back over it.+ const csvFile = join(TMP, 'import.csv'); writeFileSync(csvFile, got.csv);+ await ip.click('#btnImport'); await settle(ip, 100);+ [ch] = await Promise.all([ip.waitForFileChooser({ timeout: 4000 }), ip.click('#importMenu .dd-item[data-fmt="csv"]')]);+ await Promise.all([ip.waitForNavigation({ waitUntil: 'networkidle0', timeout: 10000 }).catch(() => null), ch.accept([csvFile])]);+ await settle(ip, 300);+ ipl = await exportPlan(I.url);+ check('Import CSV (the main plan\'s CSV export) brings in every row', spanOf(ipl).n === n && !!rowOf(ipl, r => r.name === 'Launch finale'), `${spanOf(ipl).n} rows`);+ await ip.close();+}++async function sectionsFlow(browser) {+ section = 'sections';+ const X = await startServer({ project: { title: 'Sections Test', appName: 'sections-test' },+ phases: [{ name: 'Alpha', start: '2026-09', end: '2026-12' }, { name: 'Beta', start: '2026-09', end: '2026-12' }, { name: 'Gamma', start: '2026-09', end: '2026-12' }],+ items: [{ type: 'task', id: 'a', name: 'Alpha work', start: '2026-10-05', end: '2026-10-09', phase: 0, uid: '00001' },+ { type: 'task', id: 'b', name: 'Beta work', start: '2026-10-05', end: '2026-10-09', phase: 1, uid: '00002' }] }, 'sections');+ const page = await openPage(browser, X.url);+ const names = async () => (await savedPlan(X.url)).phases.map(p => p.name);+ const rowBtn = (i, a) => page.click(`.sec-row[data-i="${i}"] .sec-btn[data-a="${a}"]`);+ await page.click('#btnSections'); await settle(page, 150);+ await page.type('#secNew', 'Delta'); await page.click('#secAdd'); await settle(page, 300);+ check('Add section appends it', JSON.stringify(await names()) === '["Alpha","Beta","Gamma","Delta"]', JSON.stringify(await names()));+ await rowBtn(0, 'down'); await settle(page, 200);+ let nm = await names(); let pl = await exportPlan(X.url);+ check('Move down swaps a section with the next, and its rows go with it', JSON.stringify(nm) === '["Beta","Alpha","Gamma","Delta"]' && rowOf(pl, r => r.id === 'a').phase === 1 && rowOf(pl, r => r.id === 'b').phase === 0, JSON.stringify(nm));+ await rowBtn(1, 'up'); await settle(page, 200);+ check('Move up swaps it back', JSON.stringify(await names()) === '["Alpha","Beta","Gamma","Delta"]');+ await rowBtn(2, 'edit'); await settle(page, 100);+ await page.$eval('.sec-row[data-i="2"] .sec-nm', e => { e.value = 'Gamma renamed'; });+ await page.click('.sec-row[data-i="2"] .sec-ok'); await settle(page, 200);+ check('Edit renames a section', (await names())[2] === 'Gamma renamed');+ await page.click('.sec-row[data-i="2"] .sec-sw'); await settle(page, 100);+ const hex = await page.$eval('.sec-row[data-i="2"] .sec-chip[data-hex^="#"]', b => { b.click(); return b.dataset.hex; }); await settle(page, 200);+ check('the colour swatch recolours the section', (await savedPlan(X.url)).phases[2].color === hex, `${hex} vs ${(await exportPlan(X.url)).phases[2].color}`);+ await rowBtn(3, 'del'); await settle(page, 100);+ await page.click('.sec-row[data-i="3"] .sec-go'); await settle(page, 200);+ check('Remove deletes an empty section', JSON.stringify(await names()) === '["Alpha","Beta","Gamma renamed"]', JSON.stringify(await names()));+ await rowBtn(1, 'del'); await settle(page, 100);+ await page.select('.sec-row[data-i="1"] .sec-dest', '0');+ await page.click('.sec-row[data-i="1"] .sec-go'); await settle(page, 200);+ pl = await savedPlan(X.url);+ check('Remove on a populated section moves its rows to the chosen one first', JSON.stringify(pl.phases.map(p => p.name)) === '["Alpha","Gamma renamed"]' && rowOf(pl, r => r.id === 'b').phase === 0, JSON.stringify(pl.phases.map(p => p.name)));+ await page.click('#secDone'); await settle(page, 150);+ check('Done closes the Sections panel', !(await page.$('.sec-back')));+ await page.close();+}++// ── Cleanup: always, however the run ends ──+// The normal path, an external SIGINT/SIGTERM (Ctrl+C, a CI timeout) and the watchdog+// all come through here, so no server, Chrome or temp dir is left behind.+let BROWSER = null, cleanedUp = false, UNTESTED = null;+function cleanupSync() {+ if (cleanedUp) return; cleanedUp = true;+ for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }+ try { BROWSER?.process()?.kill('SIGKILL'); } catch {}+ if (!KEEP) { try { rmSync(TMP, { recursive: true, force: true }); } catch {} }+}+for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => {+ console.log(`\n${sig}: stopping, killing ${servers.length} server(s) and Chrome, removing ${TMP}`);+ cleanupSync(); process.exit(130);+});+// A global watchdog: a hung page or a wedged server must not hold the run open forever.+const WATCHDOG_MS = +(process.env.UI_TEST_WATCHDOG_MS || 30 * 60 * 1000);+const watchdog = setTimeout(() => {+ console.error(`\nWATCHDOG: the suite ran past ${WATCHDOG_MS / 60000} min; aborting (last flow: ${section || '-'})`);+ console.log(`\n${pass} passed, ${fail + 1} failed`);+ cleanupSync(); process.exit(2);+}, WATCHDOG_MS);+watchdog.unref();+ let exitCode = 1; try { await main();@@ -962,10 +1593,13 @@ try { } finally { for (const s of servers) { try { s.proc.kill('SIGTERM'); } catch {} } await sleep(200);- for (const s of servers) { try { s.proc.kill('SIGKILL'); } catch {} }+ cleanupSync(); if (KEEP) console.log(`\nkept: temp state ${TMP}, screenshots ${OUT}`);- else rmSync(TMP, { recursive: true, force: true });- console.log(`\n${pass} passed, ${fail} failed`);+ if (UNTESTED) {+ console.log(`\n${UNTESTED.length} manifest entries are declared UNTESTED (no flow proves their effect):`);+ for (const d of UNTESTED) console.log(` - ${entryId(d)} (${d.label}): ${d.untested}`);+ } else if (ONLY) console.log(`\n--only ${ONLY.join(',')}: manifest honesty gates and the untested count run on a full run only`);+ console.log(`\n${pass} passed, ${fail} failed${UNTESTED ? `, ${UNTESTED.length} controls untested` : ''}`); if (failures.length) console.log('Failures:\n - ' + failures.join('\n - ')); process.exit(fail ? 1 : exitCode); }