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.
13 KiB
name, description
| name | description |
|---|---|
| dc-cypress | 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
-
SASjs Server, JS mode. Download the linux build from the sasjs/server releases, then create a
.envnext to the binary:RUN_TIMES=js NODE_PATH=node CORS=enable WHITELIST=http://localhost:4200Start it (
./api-linux). It runs in desktop mode on :5000 with no authentication. -
Deploy the mocks.
cd sas/mocks/sasjs && sasjs cbd -t server-ciTo start from a clean appLoc, delete it first:
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc" -
Seed the mock database.
cd sas/mocks && sasjs request services/admin/makedata -t server-ci \ -d deploy/makedata.json -o ./makedata_out.jsonmakedatareplies with HTML (it is normally called as a URL redirect), so the CLI reportsinvalid Json string. That is expected - check the drive instead. -
Frontend. Point the
sasjstag inclient/src/index.htmlat the mock backend (serverUrl="http://localhost:5000",appLoc="/Public/app/dc",serverType="SASJS"- a local-only edit, the repo default isserverUrl=""), thennpx ng serve --host 0.0.0.0 --port 4200.client/cypress.env.jsonneedsusername/passwordentries; any values work,loginAndUpdateValidKey()no-ops when there is no login form. -
Run.
cd client && npx cypress run --browser electron --spec cypress/e2e/<spec>.cy.tsOnly 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`)thency.loginAndUpdateValidKey(true). Thetrue` 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'). Thehomevisit is what triggers the startup-data fetch; going straight toview/dataleaves 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-loadingdisappearing. - 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 classhiddenrather than sleeping. - Lint:
cd client && npm run lint:check(prettier coverscypress/e2e/*.cy.ts).
Selectors that work
- Viewer search box:
input[name="search-input"](Enter or the magnifier icon submits); the numeric toggle isinput[name="numeric_check"]. - Row/column count in the header:
.title-col prenders(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'ssasparams.FILTER_TEXTis neither blank,' ', nor'1=1'. - Empty result:
.noData h3withNo data found with given conditions. - Grid:
#hotTable .ht_master.handsontable .htCore tbody tr(body rows),#hotTable .ht_clone_top .htCore thead(headers, andbutton.changeTypeconfirms 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-menucontainsFilter->#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.tsopens 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.tspasses it through fromRECORD_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=truedrops the steps that legitimately match nothing (wrong case, partial number, value not present), so the recording never shows an empty screen.demoPause=1500pauses 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 sprinklingcy.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 insas/mocks/sasjs/services/dcMockUtils.js(fetchTable/parseCsvfor%webout(FETCH),webOutOpen/webOutObj/webOutClosefor%webout,mpeFilterMaster,mpFilterGenerate,mpFilterStore,getDdType,formatSasValue,sasVarsEntry). - Mock data is one JSON file per table at
<drive>/files/<appLoc>/data/<LIBREF>/<table>.json, written byservices/admin/makedata.js(which also writesservices/settings.js). The drive lives under the SASjs Server directory (sasjs_root/drive). makedata.jsself-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.jsonce ignoredFILTER_RKand hard-codedFILTER_TEXTto blank, so the viewer's filter silently filtered nothing and its info bar never showed a clause, while the realviewdata.sasapplies%mpe_filtermasterand 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-cirebuilds the mock services and re-deploys the streamed web app fromclient/dist; it does not rebuild the Angular app.- Deleting the drive folder before deploying avoids stale services and stale data.
- Captured
_weboutJSON, request logs and iteration snapshots belong intmp/(gitignored), never insas/mocks/- see thedc-sasskill. - The mock server has no auth, so
cy.visiton/SASLogon/logoutreturns 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:checkpasses. -
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/executethen check
sasparams[0].NOBS, the row count andFILTER_TEXT. Pin those exact numbers in the spec.