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.
173 lines
13 KiB
Markdown
173 lines
13 KiB
Markdown
---
|
|
name: dc-cypress
|
|
description: >
|
|
Use this skill when writing or debugging Cypress E2E specs in the dc repo, or when working on the
|
|
JS mock backend they run against. Covers the SASjs Server mock environment (deploy, seed, ports),
|
|
the spec conventions the CI harness expects, the viewer/editor DOM selectors that the existing
|
|
specs get wrong, the fixed 1280x720 video capture, and the rule that a mock service must mirror
|
|
the real SAS service it stands in for.
|
|
---
|
|
|
|
# Cypress E2E and the JS mock backend
|
|
|
|
The specs under `client/cypress/e2e/` run against a mock backend: JS stored programs under `sas/mocks/sasjs/services/` executed by SASjs Server in JS-only mode. No SAS licence, no SAS server. CI (`.gitea/workflows/build.yaml`) builds the same environment - SASjs Server on :5000, `sasjs cbd -t server-ci`, makedata, then `ng serve` on :4200 - and runs a fixed list of specs against it.
|
|
|
|
This skill covers the parts that are not obvious from reading a spec file: how to stand the environment up, the selectors that actually work, and the traps in the mock services. It does not cover the SAS-side tests (see `.agents/docs/testing.md` and the `dc-sas` skill).
|
|
|
|
## When to Use
|
|
|
|
- Adding or changing a spec under `client/cypress/e2e/`.
|
|
- A mock service needs a new behaviour, or a spec "does nothing" because the mock ignores an input.
|
|
- Recording a demo video from a spec run.
|
|
- Debugging a spec that passes locally but fails in CI (usually a viewport or timing assumption).
|
|
|
|
Don't use for: SAS backend tests (`sasjs test -t 4gl`), Angular unit tests (`npm run test:headless`), or general SAS development (use the `dc-sas` skill).
|
|
|
|
## Stand up the mock environment
|
|
|
|
1. **SASjs Server, JS mode.** Download the linux build from the sasjs/server releases, then create a `.env` next to the binary:
|
|
|
|
RUN_TIMES=js
|
|
NODE_PATH=node
|
|
CORS=enable
|
|
WHITELIST=http://localhost:4200
|
|
|
|
Start it (`./api-linux`). It runs in desktop mode on :5000 with no authentication.
|
|
|
|
2. **Deploy the mocks.**
|
|
|
|
cd sas/mocks/sasjs && sasjs cbd -t server-ci
|
|
|
|
To start from a clean appLoc, delete it first:
|
|
|
|
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
|
|
|
|
3. **Seed the mock database.**
|
|
|
|
cd sas/mocks && sasjs request services/admin/makedata -t server-ci \
|
|
-d deploy/makedata.json -o ./makedata_out.json
|
|
|
|
`makedata` replies with HTML (it is normally called as a URL redirect), so the CLI reports `invalid Json string`. That is expected - check the drive instead.
|
|
|
|
4. **Frontend.** Point the `sasjs` tag in `client/src/index.html` at the mock backend (`serverUrl="http://localhost:5000"`, `appLoc="/Public/app/dc"`, `serverType="SASJS"` - a local-only edit, the repo default is `serverUrl=""`), then `npx ng serve --host 0.0.0.0 --port 4200`. `client/cypress.env.json` needs `username`/`password` entries; any values work, `loginAndUpdateValidKey()` no-ops when there is no login form.
|
|
|
|
5. **Run.**
|
|
|
|
cd client && npx cypress run --browser electron --spec cypress/e2e/<spec>.cy.ts
|
|
|
|
Only Electron is installed here (no system Chrome), so always pass `--browser electron`.
|
|
|
|
## Spec conventions
|
|
|
|
- Start the file with `export {}` so its top-level consts do not collide with other spec files under the TS type-checker.
|
|
- `beforeAll`: `cy.visit(\`${hostUrl}/SASLogon/logout\`)` then `cy.loginAndUpdateValidKey(true)`. The `true` forces a licence key instead of relying on an earlier spec in the same run having applied one.
|
|
- `beforeEach`: `cy.visit(hostUrl + appLocation)`, `visitPage('home')`, `visitPage('view/data')`. The `home` visit is what triggers the startup-data fetch; going straight to `view/data` leaves the nav tree empty in slower environments.
|
|
- Declare helpers at the **bottom of each spec file** (that is the house pattern) - do not introduce a shared helper module.
|
|
- Use `Cypress.env('longerCommandTimeout')` for waits and gate on `.app-loading` disappearing.
|
|
- **No artificial delays.** These specs are the CI regression suite, so `cy.wait(n)` to "let a result land" is not acceptable - assert on the resulting state instead (Cypress retries assertions). Replace a sleep with a state assertion: e.g. after picking an option from a soft-select, wait for `#datalist_<inputId>` to have class `hidden` rather than sleeping.
|
|
- Lint: `cd client && npm run lint:check` (prettier covers `cypress/e2e/*.cy.ts`).
|
|
|
|
## Selectors that work
|
|
|
|
- Viewer search box: `input[name="search-input"]` (Enter or the magnifier icon submits); the numeric toggle is `input[name="numeric_check"]`.
|
|
- Row/column count in the header: `.title-col p` renders `(1,000 rows, 9 cols)` - and `(1 row, 9 cols)` when the result is a single row, so an assertion on the count has to pluralise.
|
|
- Filter info bar: `.infoBar b` - only rendered when the backend's `sasparams.FILTER_TEXT` is neither blank, `' '`, nor `'1=1'`.
|
|
- Empty result: `.noData h3` with `No data found with given conditions`.
|
|
- Grid: `#hotTable .ht_master.handsontable .htCore tbody tr` (body rows), `#hotTable .ht_clone_top .htCore thead` (headers, and `button.changeType` confirms Handsontable has finished rendering them).
|
|
- Nav tree: `.nav-tree clr-tree > clr-tree-node` -> `.clr-tree-node-content-container .clr-treenode-content p` (expands a library) -> `.clr-treenode-link` (table links).
|
|
- **Viewer filter modal** - `.filterSide` (the options button) -> `.dropdown-menu` contains `Filter` -> `#vals_var_id0_0` (variable, pick from `#datalist_vals_var_id0_0 option`) and `#vals_0_0` (value) -> `.filter-modal button[aria-label="Ok"]`.
|
|
- `filtering.cy.ts` opens the filter with `.btnCtrl .btnView`, which is an **editor** control and does not exist in the viewer. Do not copy that helper into a viewer spec.
|
|
- The soft-select inputs drop a transparent click-catcher over the modal while their suggestion list is open, so Cypress reports the Ok button (and the next input) as covered by `.overlay`. Send the Escape keyup a user would send (`trigger('keyup', { key: 'Escape', force: true })`) and wait for the list to close before clicking on.
|
|
|
|
## Recording a demo video
|
|
|
|
`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 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.
|
|
|
|
That makes the window size the only lever that matters:
|
|
|
|
- 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).
|
|
|
|
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:
|
|
|
|
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>-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 (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 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.
|
|
|
|
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
|
|
|
|
A spec that doubles as a walkthrough should be a single `it()`: open the table once and make each variation a search in place. The recording then reads as a demo rather than a sequence of page loads, and it is far faster - every extra `it()` pays for the `beforeEach` navigation again.
|
|
|
|
Two recording-only env flags, both inert in CI because CI passes no env:
|
|
|
|
const skipNegative = `${Cypress.env('skipNegative')}` === 'true'
|
|
const negativeStep = (step: () => void) => {
|
|
if (!skipNegative) step()
|
|
}
|
|
const demoPauseMs = Number(Cypress.env('demoPause')) || 0
|
|
|
|
negativeStep(() => {
|
|
searchFor('trench')
|
|
assertNoData()
|
|
})
|
|
|
|
- `skipNegative=true` drops the steps that legitimately match nothing (wrong case, partial number, value not present), so the recording never shows an empty screen.
|
|
- `demoPause=1500` pauses 1.5s at each row-count assertion, so each result can be read. Apply it in one place (the row-count assertion, which every beat makes exactly once) rather than sprinkling `cy.wait()` through the test - CI still runs the file with no waits at all.
|
|
|
|
More beats, not a slower take, is what makes a demo longer: a walkthrough of 12 row-count beats takes 52s against 30s for 7.
|
|
|
|
## Mock services
|
|
|
|
- One JS file per service under `sas/mocks/sasjs/services/**`, deployed as SASjs stored programs. Shared helpers live in `sas/mocks/sasjs/services/dcMockUtils.js` (`fetchTable`/`parseCsv` for `%webout(FETCH)`, `webOutOpen`/`webOutObj`/`webOutClose` for `%webout`, `mpeFilterMaster`, `mpFilterGenerate`, `mpFilterStore`, `getDdType`, `formatSasValue`, `sasVarsEntry`).
|
|
- Mock data is one JSON file per table at `<drive>/files/<appLoc>/data/<LIBREF>/<table>.json`, written by `services/admin/makedata.js` (which also writes `services/settings.js`). The drive lives under the SASjs Server directory (`sasjs_root/drive`).
|
|
- `makedata.js` self-destructs after a successful run (it deletes itself from the drive so the frontend can detect completion), so re-deploy before re-seeding.
|
|
- **A mock must mirror the real service.** `viewdata.js` once ignored `FILTER_RK` and hard-coded `FILTER_TEXT` to blank, so the viewer's filter silently filtered nothing and its info bar never showed a clause, while the real `viewdata.sas` applies `%mpe_filtermaster` and then `%mp_searchdata`. If a mock is a deliberate stub, say so in a comment and make the divergence explicit - a mock that quietly drops an input turns an E2E spec into a test of nothing.
|
|
|
|
## Pitfalls
|
|
|
|
- `sasjs cbd -t server-ci` rebuilds the *mock services* and re-deploys the streamed web app from `client/dist`; it does not rebuild the Angular app.
|
|
- Deleting the drive folder before deploying avoids stale services and stale data.
|
|
- Captured `_webout` JSON, request logs and iteration snapshots belong in `tmp/` (gitignored), never in `sas/mocks/` - see the `dc-sas` skill.
|
|
- The mock server has no auth, so `cy.visit` on `/SASLogon/logout` returns 404 - that is fine, Cypress does not fail on the status code.
|
|
|
|
## Verification
|
|
|
|
- `npx cypress run --browser electron --spec <specs>` - all green, and run it more than once when you have just removed a wait.
|
|
- `cd client && npm run lint:check` passes.
|
|
- For a mock change, hit the service directly before writing UI assertions:
|
|
|
|
curl -sS -H "Content-Type: application/json" -X POST \
|
|
--data '{"_program":"/Public/app/dc/services/public/viewdata","SASControlTable":"LIBDS:$41. FILTER_RK:best. SEARCHTYPE:$4 SEARCHVAL:$1000\nDC_JSLIB.MPE_X_SEARCH,0,CHAR,Trench"}' \
|
|
http://localhost:5000/SASjsApi/stp/execute
|
|
|
|
then check `sasparams[0].NOBS`, the row count and `FILTER_TEXT`. Pin those exact numbers in the spec.
|