test(e2e): scroll the grid so the NOTES column is on screen for the siphonophore beat
Build / Build-and-ng-test (pull_request) Failing after 1m36s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m18s

The three-row siphonophore result is narrower than the grid viewport, so
Handsontable sizes the columns wider than it (scrollWidth 1562 vs
clientWidth 1340): a horizontal scrollbar appears and NOTES - the column the
match is actually in - is clipped. The cell text is in the DOM either way, so
the beat passed without it, but the demo read as three arbitrary rows.

scrollGridTo() sets scrollLeft on #hotTable .wtHolder, asserts the value
landed, and the beat asserts the full NOTES text and scrolls back for the
following beats. It is a plain spec step - CI runs it, no recording flag and
no cy.wait.

Also lets a recording size the browser window: the capture is the window's
content area, so RECORD_WINDOW_SIZE is passed through as --window-size for a
real Chromium browser (Electron ignores it). That is what makes a 16:9 pane
possible: (W-450)/(H-96) = 16/9 at 1920x923 of content, i.e. 1920x1010 of
window, for a 1470x827 pane and the repo's 1600x900 viewport at ~0.90 zoom.

Inert unless the env var is set, so CI is unaffected. dc-cypress updated with
the window-sizing recipe, the clipped-column scroll and the frame
mean/variance scan used to find the cut point.
This commit is contained in:
dc committed 2026-09-16 18:44:25 +00:00
1 parent 37c6e50cb6
commit aae48dc401
3 files changed
+87 -13

No files matched your search

+32 -12
View File
@@ -81,27 +81,47 @@ Don't use for: SAS backend tests (`sasjs test -t 4gl`), Angular unit tests (`npm
## Recording a demo video
`video: true` in `cypress.config.ts` is all that is needed. Run the spec with `--browser electron` and the MP4 lands in `client/cypress/videos/<spec>.mp4`.
`video: true` in `cypress.config.ts` is all that is needed. Run the spec and the MP4 lands in `client/cypress/videos/<spec>.mp4`.
**The capture is a fixed 1280x720 window, and it contains the Cypress runner** - the command log takes the left ~450px and the app is rendered in the remaining pane. Nothing changes that: `viewportWidth`/`viewportHeight`, `--force-device-scale-factor`, a CSS `zoom`, a Chrome-for-Testing binary, and a 2560x1440 Xvfb display were all tested and all leave the frame at 1280x720 (Electron) or 1280x632 (Chrome). Cypress starts its own Xvfb for the run (a second display appears in `/tmp/.X11-unix`), so an external `DISPLAY` is ignored.
**The capture is the browser window's content area, and it contains the Cypress runner** - the command log takes the left ~450px and the app is rendered in the remaining pane, so the pane is `(capture width - 450) x (capture height - 96)` and the app is zoomed to fit it.
What the viewport config *does* control is the zoom the app is rendered at inside that pane - Cypress zooms the AUT to fit the pane. So a 1600x900 viewport renders at 50% and leaves most of the pane's height unused (the app occupies ~806x452 of it), while a viewport whose aspect ratio matches the pane (~830x624, i.e. ~1.33) fills it at ~75%:
That makes the window size the only lever that matters:
npx cypress run --browser electron \
--config viewportWidth=1100,viewportHeight=827 \
--spec cypress/e2e/full-table-search.cy.ts --env skipNegative=true,demoPause=1500
- Electron is stuck at 1280x720 (it ignores `--window-size`), i.e. an 830x624 pane, and the repo's 1600x900 viewport renders at 50% - the app only occupies ~806x452 of it.
- A real Chromium browser honours `--window-size`, so the capture can be sized to whatever the pane needs. `cypress.config.ts` passes it through from `RECORD_WINDOW_SIZE` (inert in CI, which sets no env).
The app then occupies ~793x596 of the 1280x720 frame. Crop that region out and the runner disappears:
For a 16:9 pane, solve `(W - 450) / (H - 96) = 16/9`: W = 1920 gives H = 923 of window *content*, and Chrome adds ~87px of window chrome above that, so pass 1920x1010:
ffmpeg -i cypress/videos/<spec>.mp4 -vf "crop=792:596:468:80" \
RECORD_WINDOW_SIZE=1920,1010 npx cypress run \
--browser /opt/data/profiles/dc/tools/chrome-linux64/chrome \
--spec cypress/e2e/full-table-search.cy.ts \
--env skipNegative=true,demoPause=1500
That yields a 1920x922 capture with a 1470x826 pane (16:9) and, at the repo's 1600x900 viewport, an app at ~0.90 zoom occupying 1441x812 - about 1.9x the linear size of the 16:9 crop Electron can give. Crop the app out:
ffmpeg -ss <start> -i cypress/videos/<spec>.mp4 -vf "crop=1440:812:464:80" \
-an -c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p \
-movflags +faststart <spec>-apponly.mp4
-movflags +faststart <spec>-cut.mp4
Measure the crop rect from the frames rather than guessing: the pane's flat background is rgb(225,227,236), so a row/column scan for pixels that differ from it gives the app's bounding box (runner pane ends x=449, app x=468-1259, app y=80-675 for a 1100x827 viewport).
Measure the crop rect from the frames rather than guessing: the pane's flat background is rgb(225,227,236), so a row/column scan for pixels that differ from it gives the app's bounding box (for the 1920x922 capture: runner pane ends x=449, app x=463-1903, app y=80-893; for the 1280x720 Electron capture: app x=468-1259, y=80-675). The app's own background is nearly the same grey, so verify the result on the first, middle and last frames rather than trusting a pixel test alone.
To find where to cut, scan frame variance in the content area: the "Loading Table Viewer" spinner is a nearly flat frame (std dev ~13) and the rendered table is not (~29), so the first high-variance frame is where the table appears. Do not cut on a guessed timestamp - cutting 0.2s early shows a spinner flash.
To find where to cut, scan the mean *and* standard deviation of the content area: the app boots mid-run (a dark screen, mean ~65, sd ~2.5), the "Loading Table Viewer" spinner is flat and light (mean ~252, sd ~0), and the rendered table is mean ~245, sd ~31. Cut on the first table frame - cutting a fraction early shows the boot screen or the spinner.
Chrome for Testing (the only non-Electron browser installable here - no system Chrome, no apt) works with Cypress via `--browser /path/to/chrome-linux64/chrome`, but its capture is 1280x632, i.e. a shorter AUT pane than Electron's. Use Electron.
Cypress starts its own Xvfb for the run (a second display appears in `/tmp/.X11-unix`), so an external `DISPLAY` is ignored; `--force-device-scale-factor` and a CSS `zoom` also do not change the capture.
### Grid columns that are clipped
Handsontable sizes columns to content, so a narrow result can be *wider* than the grid viewport: the horizontal scrollbar appears and the rightmost column (NOTES on `MPE_X_SEARCH`) is cut off. The cell text is still in the DOM, so an assertion passes either way - the column only has to be scrolled into view for the *video* to show why the rows matched.
const scrollGridTo = (position: 'left' | 'right') => {
cy.get('#hotTable .wtHolder').first().then(($holder: any) => {
const el = $holder[0]
el.scrollLeft = position === 'right' ? el.scrollWidth : 0
expect(el.scrollLeft).to.equal(position === 'right' ? el.scrollWidth - el.clientWidth : 0)
})
}
Setting `scrollLeft` is enough - Handsontable keeps the header clone in step - and the browser clamps it to `scrollWidth - clientWidth`, so that is what scrolling right has to land on. This is a legitimate spec step (it runs in CI), not a recording flag.
### A longer, more readable demo
+15
View File
@@ -38,9 +38,24 @@ export default defineConfig({
// Pin the browser locale so locale-formatted cells (intl-date/time/datetime)
// render deterministically regardless of the runner's system locale.
on('before:browser:launch', (browser, launchOptions) => {
// Recording only: the video capture is the browser window, which
// contains the Cypress runner - the command log takes the left ~450px
// and the app sits in the remaining pane. Sizing the window is the
// only lever that moves the capture, and only for a real Chromium
// browser (Electron ignores it). For a 16:9 pane, solve
// (W - 450) / (H - 96) = 16/9: W = 1920 -> H = 923 of window content,
// so pass RECORD_WINDOW_SIZE=1920,1010 and crop the pane.
// Inert unless the env var is set, so CI is unaffected.
if (process.env.RECORD_WINDOW_SIZE && browser.family === 'chromium') {
launchOptions.args.push(
`--window-size=${process.env.RECORD_WINDOW_SIZE}`
)
}
if (browser.family === 'chromium' && browser.name !== 'electron') {
launchOptions.args.push('--lang=en-GB')
}
return launchOptions
})
}
+40 -1
View File
@@ -83,10 +83,17 @@ context('full table search tests: ', function () {
// Partial character search: "siphonophore" only ever appears in the middle
// of the NOTES text, so a match proves the search is not column-specific.
// Three rows is narrow enough that the grid grows a horizontal scrollbar
// and clips NOTES - the column the match is actually in - so scroll it
// into view, assert on what is now on screen, then put the grid back.
searchFor('siphonophore')
assertRowCount('3')
scrollGridTo('right')
pauseForDemo()
assertGridContains(['Oceanus Rise', 'Nereid Trench', 'Halcyon Trench'])
assertGridExcludes(['Kraken Hollow', 'Vostok Deep'])
assertGridContains(['bioluminescent siphonophore was filmed here'])
scrollGridTo('left')
// A search spans every column at once: "Halcyon" is both a site (Halcyon
// Trench) and a vessel (RV Halcyon), and the 280 matches are the union.
@@ -192,6 +199,12 @@ const visitPage = (url: string) => {
// --env skipNegative=true,demoPause=1500
const demoPauseMs = Number(Cypress.env('demoPause')) || 0
// Linger for a recording. A no-op unless demoPause is set, which only the
// recording run does - CI passes no env.
const pauseForDemo = () => {
if (demoPauseMs > 0) cy.wait(demoPauseMs)
}
// The row/column count the viewer shows next to the table name, e.g.
// "(1,000 rows, 9 cols)". The viewer pluralises: a single-row result reads
// "(1 row, 9 cols)".
@@ -203,7 +216,33 @@ const assertRowCount = (rows: string) => {
}
)
if (demoPauseMs > 0) cy.wait(demoPauseMs)
pauseForDemo()
}
// The grid body is a Handsontable .wtHolder, and a narrow result is wider than
// the grid viewport, which clips the rightmost column (NOTES). Setting
// scrollLeft is enough - Handsontable keeps the header clone in step - and the
// assertion right after proves the scroll landed rather than assuming it. The
// browser clamps scrollLeft to (scrollWidth - clientWidth), so that is what
// scrolling right has to land on.
const scrollGridTo = (position: 'left' | 'right') => {
cy.get('#hotTable .wtHolder')
.first()
.then(($holder: any) => {
const el = $holder[0]
const maxScroll = el.scrollWidth - el.clientWidth
el.scrollLeft = position === 'right' ? el.scrollWidth : 0
if (position === 'right') {
// This step only means anything if the grid really is clipping a
// column - fail loudly rather than silently doing nothing.
expect(el.scrollWidth).to.be.greaterThan(el.clientWidth)
expect(el.scrollLeft).to.equal(maxScroll)
} else {
expect(el.scrollLeft).to.equal(0)
}
})
}
// A search that matches nothing replaces the grid with the viewer's "no data"