Compare commits

..
Author SHA1 Message Date
YuryShkoda 2411443ec7 fix(cypress): make licensing combined-key tests order-independent
- Tests 7-10 only acted when already on the licensing page, silently
  no-oping otherwise; now they navigate to licensing/update explicitly
  when needed, chained via .then() to avoid Cypress conflating a raw
  async function's promise with the preceding cy.visit()/cy.wait()
- Replace Response(readable).arrayBuffer() with a direct
  ReadableStream reader loop in generateCombinedKey - the Response-based
  read never settled under Cypress, likely due to its fetch/Response
  patching for network interception
2026-08-18 16:29:06 +03:00
YuryShkoda 586b9c0f1d feat(licensing): support combined-key paste, object-format features, and a live key preview
- Accept a single pasted/uploaded combined licence key (base64+gzip of
  licence+activation key), auto-detected in either paste mode, alongside
  the legacy two-field format
- Warn and block applying a key generated for the wrong protocol before
  it ever reaches the backend
- Decode both the legacy positional features string and a new named
  features object, so already-issued keys keep working unchanged
- Show a live "Key Details" preview (validity, users, site IDs, enabled
  features) as a key is typed, pasted, or uploaded
- Fix cypress test setup broken by the paste-format default change, and
  an unreliable FileReader-based base64 conversion in the test helpers
2026-08-17 12:04:42 +03:00
574 changed files with 30107 additions and 71553 deletions

No files matched your search

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,11 +1,10 @@
# Testing
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory. For a deploy-and-run on the shared state see the `dc-estate-testing` skill (`.agents/skills/dc-estate-testing/SKILL.md`), which covers the credentials, the `makedata` seed the suite needs and the estate's group name.
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
## Commands
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
- The shared estate under its `server` target: `npm run server` (compile, deploy, seed) then `sasjs test -t server`.
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
File renamed without changes.
File renamed without changes.
File renamed without changes.
+73
View File
@@ -0,0 +1,73 @@
---
name: sas
description: >
Use this skill whenever writing, modifying, or debugging SAS code in this repository — SAS
macros, sasjs services, hooks, or test files (*.sas). Triggers include: SAS macro compilation
errors, parameter parsing issues, writing sasjs tests with mp_assert / mp_assertdsobs /
mp_assertscope, macro-quoting questions (%str, %nrstr, %superq), %mp_abort usage, or unexpected
behaviour when passing free-text values (descriptions, messages) to macros. Also covers repo
conventions such as running `sasjs lint` after touching .sas files and the maximum line length
rule.
---
# SAS Development
Conventions and pitfalls for SAS code in this repository (macros in `sas/sasjs/macros/`, services
in `sas/sasjs/services/`, tests alongside them as `*.test.sas`).
## Always `%str()` free-text macro parameters
A comma inside a macro parameter value is parsed as a **parameter delimiter**. Free-text
parameters such as `desc=` (in `%mp_assert`, `%mp_assertdsobs`, `%mp_assertscope`) and `msg=` (in
`%mp_abort`) must be wrapped in `%str()` — even when they currently contain no comma, so later
edits cannot silently break the call.
Wrong — the text after the comma becomes an unexpected positional parameter and the compilation
fails (or worse, is misparsed):
```sas
%mp_assert(iftrue=(&oldrows=0),
desc=Test 2 - all staged records loaded, delete flags ignored,
outds=work.test_results
)
```
Right:
```sas
%mp_assert(iftrue=(&oldrows=0),
desc=%str(Test 2 - all staged records loaded, delete flags ignored),
outds=work.test_results
)
```
The same applies to any macro parameter that carries user-facing text: `%mp_abort(msg=%str(...))`,
`etlsource=` values containing punctuation (use `%superq()` when passing macro variables that may
contain special characters), etc.
Related quoting rules of thumb:
- `%str()` masks commas, parentheses, semicolons and quotes at compile time — sufficient for
static text like `desc=`.
- Use `%nrstr()` if the text must also mask `%` and `&` (rare in descriptions).
- Use `%superq(var)` when forwarding a macro **variable** whose value may contain special
characters (e.g. `etlsource=` in `mpe_targetloader.sas`).
## Tests must be idempotent
A test file must pass when run repeatedly (including after a run that failed partway). Conventions:
- Start the file with `%let syscc=0;` — many macros (e.g. `%mp_lockanytable(LOCK)`) abort on entry if `&syscc>0`, and any `WARNING` in a previous test bumps `syscc` to 4.
- Clean up **all** persistent state, not just the obvious one: `MPE_TABLES` registrations, `MPE_LOCKANYTABLE` lock records (a run dying between LOCK and UNLOCK leaves a stale `LOCKED` row), physical tables in `dctest` (`proc datasets ... delete`), and global macro variables created via `select ... into:` (`%symdel`).
- Make prep defensive: delete-then-insert config records (handles leftovers from an aborted run), and recreate physical tables rather than assuming they are absent.
## Repo conventions for .sas files
- Run `npx sasjs lint` from the `sas/` directory after creating or modifying any `.sas` file; the
files you touched must have zero warnings (pre-existing warnings in other files can be ignored).
- Maximum line length is 80 characters (lint-enforced) — this is why long `msg=%str(...)` values
sometimes need shortening.
- Test files are named `<thing>.test.sas` (or `<thing>.test.N.sas`) and run via
`npm run 4gl && sasjs test -t 4gl` from `sas/` — see `.agent/docs/testing.md`.
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
`sas/sasjs/` only.
@@ -1,32 +0,0 @@
# What guards the deploy window
The deploy screens ask two questions about the physical filesystem - what folders sit below this parent, and can the deployment user write to this one. Both are answered by `makedata` (`ACTION=DIRLIST` and `ACTION=WRITECHECK`), through `mpe_dirlist.sas` and `mpe_writecheck.sas`.
This note records the two-layer answer: a gate that closes the window once the appLoc is configured, and why the window itself has no group check.
## The gate
The deploy window is over once the appLoc has `services/public/settings.sas`: `makedata` writes it at the end of a successful run, and a run that failed - or was abandoned - after writing it leaves the file behind. So the service refuses `DIRLIST` and `WRITECHECK` whenever that file is present, however the run that created it ended. On SASjs Server the file is fetched with `ms_getfile` and a probe that cannot answer (anything other than 200 or 404) is treated as configured: an unreachable drive is not evidence that the window is open. Viya checks the same file through `mfv_existfile`.
The consequence for tests: the browse actions are refused on any appLoc that runs the suite, because every such appLoc is configured. `services/admin/makedata.test.sas` therefore asserts the refusal, and the listing and write-probe behaviour is asserted against the macros directly (`macros/mpe_dirlist.test.sas`, `macros/mpe_writecheck.test.sas`). The macros' own abort paths (a relative path, one carrying a macro trigger) are not asserted anywhere: `mp_abort` cannot be exercised inside the test session.
## Why there is no group check
Every other admin service gates on `&mpeadmins`, which comes from `MPE_CONFIG` in the control library - and that library is what `makedata` creates. Before it runs there is no group to check: `%mpeinit()` reads `&DC_LIBREF..mpe_config` and has nothing to read. That is also why the previous `dirlist` service could never serve the deploy screen it was written for.
The window is not narrower than the configurator's own: on SASjs Server the configurator page, in the same window, lets the caller pick any group they belong to and create the control library - which is full Data Controller admin. `DIRLIST` and `WRITECHECK` are strictly less powerful than that.
## What the actions do, for the record
- `DIRLIST` - enumerate the folders below any absolute path the SAS process can open. Folder names only; no file contents, no file listing.
- `WRITECHECK` - ask whether a path exists, is a folder, and whether the deployment user can create and delete a file in it. It creates a uniquely named probe file and removes it again.
Both refuse a value carrying a macro trigger, a quote or a statement separator, and shape check it with `mp_validatecol(ISPATH)` before use. The shape check fails closed: an absent `ISPATH` rule refuses the path rather than accepting it.
## The assessment
- **The window is the deployment itself.** `makedata` is deployed, run once by an administrator, and deletes itself on success.
- **There is no admin group to check while it runs.** It is created by `makedata`, from the group the deployer picks in the configurator.
- **The caller is the administrator.** Reaching these actions means reaching the STPs of a deployment in progress, which is the same caller the configurator is serving in the same window.
- **The actions are the weaker half of that window.** The configurator, in the same window, lets the caller pick any group they belong to and create the control library - which is full Data Controller admin. Listing folders and probing a write cannot achieve that.
- **The window closes on the settings file, not on survival of the service.** That is what covers a failed or abandoned run.
-176
View File
@@ -1,176 +0,0 @@
---
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
cd sas/mocks && sasjs request services/admin/createdemodata -t server-ci
`makedata` builds only what a SAS deployment builds (the control library);
`createdemodata` adds the Cypress fixtures and refuses to run without it.
The payload asks for `OUTFMT=JSON`, so both calls exit non-zero on a failed
seed rather than reporting `invalid Json string`.
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.
-79
View File
@@ -1,79 +0,0 @@
---
name: dc-estate-testing
description: >
Use when deploying Data Controller to a shared SASjs Server estate (sas.4gl.io) or running the SAS
test suite against it. Covers the target and credential setup, the makedata seed the suite needs on
a fresh appLoc and the group it must name, how to read the results, and how to deploy the frontend
so the streamed app is the build you just made.
---
# Deploying and testing on the shared SASjs Server estate
The whole cycle is two npm scripts plus `sasjs test`. Everything below explains what those scripts do and the steps that go wrong.
cd sas && npm ci # macro dependencies
cd ../client && npm ci && npm run build # frontend only - see "Deploying the frontend"
cd ../sas && npm run server # cpfavicon + sasjs cbd -t server + makedata
npx sasjs test -t server
`npm run server` is `sasjs cbd -t server && npm run serverdata`, and the seed in the second half is not optional. `sasjs cbd` on its own deploys the services and the test programs but leaves the appLoc with no Data Controller configuration, and `testsetup` reads `<appLoc>/services/public/settings.sas` through the drive API. `makedata` is what creates that file, so without the seed `testsetup` gets a 404, aborts, and 59 of the 64 test programs fail to complete on setup alone - which reads like a broken deployment rather than a missing seed.
## The targets
`server` is the shared estate: `serverType: SASJS` on https://sas.4gl.io, `appLoc` `/Public/app/dc`, a real SAS 9 binary behind SASjs Server (`GET /SASjsApi/info` reports `mode: server`, `runTimes: ["sas"]`, `localLoginEnabled: true`). `4gl` is the same estate with a slightly different config, hence the older `npm run 4gl` / `4glmakedata` pair.
A run against `server` exercises the SASjs Server branch of every macro and the SAS 9 backend, and nothing else: no Viya branch, no SAS 9 STP branch. Say that when reporting, rather than implying the matrix is covered.
To deploy your own branch without replacing the app everyone else uses, add a target that copies `server` and changes `appLoc` and `streamServiceName` (for example `/Public/app/<you>/dc`), keep it out of the commit, and pass it with `-t`. `sasjs cbd` overwrites the whole appLoc it is pointed at.
## Credentials
The CLI logs in with a registered client, not a username and password: `.env.<target>` (gitignored, mode 600) needs `CLIENT`, `ACCESS_TOKEN` and `REFRESH_TOKEN`. With `CLIENT` alone it prints a `/SASjsLogon?client_id=...` URL and waits for an authorization code, which cannot be answered headless.
Mint the pair from a local login, with the password read from a file rather than argv:
1. `GET /` with a cookie jar - it sets `XSRF-TOKEN`, which every POST must echo in an `X-XSRF-TOKEN` header.
2. `POST /SASLogon/login` with `{"username","password"}`.
3. `GET /SASjsApi/client` on the same session - the registered client ids.
4. `POST /SASLogon/authorize` with `{"clientId"}` -> `{"code"}`.
5. `POST /SASjsApi/auth/token` with `{"clientId","code"}` -> `accessToken` / `refreshToken`.
An unmatched `/SASjsApi/...` path answers `200` with the Swagger page, so check the content type; HTML means the route does not exist. Delete the token file when the run is over.
## The makedata seed
`npm run serverdata` sends `deploy/makeDataServer.json`: one `fromjs` row with `ADMIN`, `DCPATH` and `OUTFMT`. Two things about it are worth knowing before you trust its result.
- **`ADMIN` must name a group the calling account is actually in.** The shipped value is `all-users`, the SASjs Server group that every logged-in user is a member of, which is what the shared estate needs. A name the account is not in - a Viya-style group such as `AllUsers`, or one estate's own group carried into another - still answers `SUCCESS` from the seed, and then every later `getdata`/`stagedata` call aborts with `<user> is not authorised to edit <lib>.<ds> in the <lib>.MPE_SECURITY table`. Check what the account belongs to with a call to `services/public/getgroups` before changing the value.
- **Each run creates a new library.** The response carries `STATUS`, `DCPATH` and `LIBREF` (for example `DC621835`), and the LIBREF is clock-derived, so a libref from an earlier run answers `dataset <lib>.<table> does not exist` - take the current one from the response.
- **`KEEPME` controls whether the seed can be repeated.** Without it `makedata` deletes itself and the configurator after a successful run (deliberately, so it cannot be executed twice over real data), and a second seed fails with `The Program at (<appLoc>/services/admin/makedata) does not exist` until you run `sasjs cbd` again. A harness that reseeds between runs sets `KEEPME` in the input row.
- **Seed once per deploy, not before every test run.** The suite builds its own `dctest` library from the seeded control tables; reseeding in between is wasted work, and there is no need to run `makedata` and `sasjs test` in the opposite order.
## Reading the results
Results land in `sas/sasjsresults/`: `testResults.json` (the counts worth quoting: `tests_with_results`, `tests_that_pass`, `failed_to_complete`, `completed_with_failures`), `testResults.csv` (one row per assertion, lower-case headers `test_target`, `test_loc`, `sasjs_test_id`, `test_suite_result`, `test_description`, `test_url`), and one SAS log per program under `logs/`. The CLI's closing lines are not the source of truth when they disagree with the JSON.
A current green run is 64 of 64 test programs with 241 assertions passing and nothing failing to complete.
`ERROR` lines in the per-program logs are expected and are not a failed run: the specs that assert a refusal get `ERROR: Execution canceled by an %ABORT CANCEL statement`, and the ones that feed bad payloads get the rejection message they are asserting on (`Invalid libds:...`, `Invalid libref:...`). Judge the run by the JSON counts, then by whether the errors you find are the ones the tests asked for.
## Deploying the frontend
The streamed web app is a separate artefact and `sasjs cbd` only streams it if `client/dist` exists:
1. `client/libraries/sheet-crypto.tgz.gpg` is an encrypted dependency (`file:libraries/sheet-crypto.tgz` in `client/package.json`) that is not on the public registry. Decrypt it with the passphrase the maintainer holds: `printf '%s' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 --output ./libraries/sheet-crypto.tgz --decrypt ./libraries/sheet-crypto.tgz.gpg`.
2. `client/.npmrc` is gitignored and needs `legacy-peer-deps=true` (the committed lock is written with it) plus the `@sheet` registry lines.
3. `cd client && npm ci && npm run build`.
4. The build carries the repo default appLoc, so point it at the deployment before deploying: rewrite `apploc="/Public/app/dc"` in `dist/index.html` to the target appLoc.
5. `cd ../sas && npx sasjs cbd -t <target>` - with `client/dist` present the log says `Copying web app files` and `Web app is available at <url>`.
Without step 1 the deploy prints `ERROR An error has occurred when compiling web app services ... webSourcePath: .../client/dist doesn't exist` and then deploys the services anyway, so the deploy looks successful while the app streams nothing. The AppStream URL is session-protected: an anonymous `curl` gets a `401`, so verify it with a logged-in cookie jar and fetch the `main.<hash>.js` the page names.
Never commit `client/.npmrc` or the decrypted tarball; both are ignored, and `git status` staying clean is the check.
## Reading a failed run
- `ERROR An error occurred while executing the request. Job location: <appLoc>/tests/...` means the response was not JSON, so the program broke before it could write results. Open `sasjsresults/logs/<suite>.log` and read the first `ERROR`; a single compile break cascades into hundreds of `ERROR 180-322: Statement is not valid` lines and the tail is noise.
- `WARNING: Apparent symbolic reference TESTLOC not resolved` in `testsetup.log`, next to a `404 Not Found` for `services/public/settings.sas`, is the missing seed described above, not a macro problem.
- `sasjs test` runs the DEPLOYED program, so after editing a service, hook or test, run `sasjs cbd` again before testing or you will debug code that is no longer in the tree.
- A deployment can succeed and still be missing its web app, its data or its configuration - check the thing you changed rather than the exit code.
-82
View File
@@ -1,82 +0,0 @@
---
name: dc-sas
description: >
Use this skill alongside the sasjs/skills collection (sas, sasjs-core, sasjs-cli) when working on
Data Controller SAS code. Covers the repo-specific SAS knowledge that is NOT in those skills —
DC test-state conventions (MPE control tables, dctest, %let syscc) and the chunked-deploy
strategy for the huge generated build script (sasjsbuild/viya.sas). For general SAS language,
@sasjs/core macro, and @sasjs/cli guidance, rely on the sas, sasjs-core and sasjs-cli skills.
---
# SAS Development in Data Controller
The sas, sasjs-core and sasjs-cli skills cover general SAS language and SASjs conventions (macro
quoting, `%mp_assert*` testing style, `sasjs lint`, `sasjs run`, `sasjsbuild/` being generated
output). These notes only cover the parts specific to Data Controller.
## Tests must be idempotent
A test file must pass when run repeatedly (including after a run that failed partway).
- Clean up **all** persistent DC state, not just the obvious one: `MPE_TABLES` registrations,
`MPE_LOCKANYTABLE` lock records (a run dying between LOCK and UNLOCK leaves a stale `LOCKED`
row), physical tables in `dctest` (`proc datasets ... delete`), and global macro variables
created via `select ... into:` (`%symdel`).
## Chunked deploy to Viya
The full DC build script (`sasjsbuild/viya.sas`) is very large (11+ MB / 300k+ lines). The Viya
compute API can hang or fail when submitting a file this size in a single request.
**Chunked deploy.** Split the build script at `%let path=<folder>` boundaries — these are clean
section breaks between service/test folders (always preceded by blank lines, never inside a data
step). Each sub-chunk must be prepended with the shared header (the first ~3827 lines containing
macro definitions, `appLoc` setup, and logging options:
`options ps=max nonotes nosgen nomprint nomlogic nosource2 nosource noquotelenmax;`
Logging is already suppressed in the header — no extra action needed. Keep each chunk under ~1.5
MB. The `tests/macros`, `tests/services`, and streaming-app tail sections are the largest and may
need further sub-splitting at `%let service=` boundaries.
When splitting mid-folder (between services in the same folder), the sub-chunk that starts
mid-folder must re-assert `%let path=<folder>;` before its first `%let service=` line — otherwise
`&path` is unresolved and the `%mv_createwebservice(path=&appLoc/&path, ...)` call will abort with
a recursive reference error.
Deploy each chunk in order with `sasjs run <chunk>.sas -t <target>`. After all chunks are
deployed, run `sasjs request services/admin/makedata -d deploy/makeDataViya.json -t <target>` to
create the database.
**Non-fatal errors.** `ERROR: Unauthorized` on `/dataSources/providers/Compute/` lines in the
log are expected for non-admin Viya users — they come from the Data Sources API during
lineage/catalog refresh and do not affect the deploy.
## Repo conventions for .sas files
- Test files are named `<thing>.test.sas` (or `<thing>.test.N.sas`) and run via
`npm run 4gl && sasjs test -t 4gl` from `sas/` — see `.agents/docs/testing.md`.
- `sas/sasjsbuild/` is generated build output — never hand-edit it; edit sources under
`sas/sasjs/` only.
## Temp and scratch files stay out of the repo
Transient artifacts from manual test runs against sasjs-server — captured `_webout` JSON
responses (e.g. `*_out.json`), request/response logs (e.g. `*.log`), and iteration snapshots
(`vd_out2.json`, `stage_check2.json`, etc.) — must not be left in the working tree. They
clutter `git status`, are never referenced by source or tests, and easily leak local
filesystem paths (the sasjs-server session dir, `/home/.../sasjs_root/sessions/.../`) into
committed history.
Write them to a gitignored tmp location instead. The repo already provides two:
- `tmp/` (gitignored at repo root) — general scratch space.
- `sas/tmp/` (gitignored via the root `tmp/` rule) — for SAS-side artifacts.
If a helper script dumps captured output for comparison during a debug session, point it at
`tmp/` (or `sas/tmp/` for SAS-local paths) rather than alongside the tracked mocks in
`sas/mocks/`. The tracked mock fixtures live under `sas/mocks/sas9/` and
`sas/mocks/sasjs/services/`; loose `*_out.json` / `*.log` files at the `sas/mocks/` root are
not mocks, they are leftover captures — remove them with `git clean -fd sas/mocks/` or
write to `tmp/` in the first place.
-63
View File
@@ -1,63 +0,0 @@
---
name: sas
description: Expert guidance for the SAS programming language — DATA step, PROC SQL, macro language, formats, ODS, and common procedures. Pure SAS syntax only, no SASjs-framework content. Use when writing, reviewing, or debugging .sas programs, or answering SAS language questions.
---
# SAS Language
Write idiomatic, production-quality SAS code. This skill covers the SAS language itself, independent of any framework.
## Scope
- DATA step programming (SET/MERGE/BY, RETAIN, arrays, DO loops, hash objects, first./last. processing)
- PROC SQL (joins, subqueries, views, pass-through with CONNECT TO)
- Macro language (%macro/%mend, macro variables, %local/%global, conditional %IF logic, quoting functions %STR/%NRSTR/%BQUOTE, CALL SYMPUTX)
- Common PROCs: SORT, MEANS/SUMMARY, FREQ, TRANSPOSE, REPORT, PRINT, CONTENTS, DATASETS, FORMAT, IMPORT/EXPORT
- Formats and informats (user-defined via PROC FORMAT, picture formats)
- ODS output (HTML, PDF, RTF, Excel, OUTPUT destination)
- File I/O: LIBNAME, FILENAME, INFILE/INPUT, FILE/PUT
## Non-negotiable: no WARNINGs
Generated SAS code must run cleanly — **zero WARNINGs (and zero ERRORs) in the log**. Treat every `WARNING:` as a defect: uninitialized variables, implicit type conversions, truncation notes that should be warnings, "no observations", unresolved macro references, etc. If a warning is truly unavoidable, suppress it deliberately (e.g. an explicit option) and comment why. Likewise avoid superfluous NOTEs where reasonable.
## Style rules
These follow the @sasjs/core coding standards — apply them to all SAS code:
- One statement per line; indentation = 2 spaces, no tabs, no trailing whitespace
- Lines no longer than 80 characters; unix (LF) line endings; UTF-8
- Avoid non-ASCII / special characters entirely — maximum compatibility across SAS installations and encodings
- Always end steps with `run;`; for `proc sql` (and CAS-connected procs) `quit;` is essential to avoid `WARNING: You cannot disconnect or terminate session ...` on Viya
- All dataset references must be 2-level (`work.blah`, not `blah`) — protects against `DATASTMTCHK=ALLKEYWORDS` and an active `USER` library
- Explicit `length` / `attrib` for character variables rather than relying on defaults (avoids implicit length=8 truncation)
- Use literal suffixes for clarity (`'01JAN2020'd`, `'12:30't`)
- Prefer `proc sort` with `nodupkey` over manual dedup logic
- Macros:
- Define with parentheses, even with no parameters: `%macro x();` not `%macro x;`
- Closing `%mend;` must repeat the macro name
- Macro calls are not terminated with a semicolon: `%my_macro()` not `%my_macro();`
- Mandatory parameters positional; optional parameters keyword (`var=`) style
- Macro names lowercase, verb-noun convention
- Macro variables without trailing dot (`&var` not `&var.`) unless needed to prevent incorrect resolution
- Macro variable NAMES are case-insensitive: `&Foo`, `&FOO`, and `&foo` all resolve to the same symbol (same for `%symexist`/`%superq`/`symget` arguments, which take a NAME not a value). Don't chase case mismatches as a bug — it's never the cause.
- ALL macro variables must be `%local` unless deliberately global (globals should use an application prefix to avoid collisions); use `call symputx` (not `symput`) in DATA steps
- Comment with `/* */` inside macros (asterisk comments are compiled into the macro)
- Guard macro logic with `%length(&var)=0` checks rather than `&var=` (empty comparisons are unsafe)
- Avoid naming collisions: use `%sysfunc`-/`&syslast`-based work tables (e.g. `data &output; set &syslast; run;`) rather than hard-coded names
- No open (non-macro) conditional code: wrap platform-branching or conditionally-executed blocks (e.g. `%if %mf_getplatform()=VIYA %then %do; ... %end;`) in a `%macro ... %mend` and invoke the macro. Open `%if` at program level fails in some execution contexts (job/scheduler/test harnesses) and hides scope leaks.
## Portability awareness
- Note when code differs between SAS 9.4 and Viya (e.g. CAS actions vs procs, `proc casutil` for sashdat loading, no X command on locked-down servers)
- Avoid hard-coded physical paths and engine-specific options unless asked
- No open macro code with if/else logic: wrap branching blocks in `%macro ... %mend` and call them — open `%if`/`%else` does not behave as expected in all SAS environments.
## Common pitfalls to flag when reviewing
- Unintended many-to-many MERGEs
- Automatic variable `_ERROR_` / implicit RETAIN surprises
- Macro timing issues: referencing `&macrovar` before it exists, `%if` evaluating data-step variables (use `if`/`symget` instead)
- Automatic macro variables (`&syscc`, `&syswarningtext`, `&syserrortext`, `&sysdate`, `&sysuserid`, etc.) are READ-ONLY — attempting to overwrite them (e.g. `%let syswarningtext=;` or `call symput('syscc',...)`) raises `ERROR: Unable to assign value to a macro variable that is read only` (or similar). Never try to "clear" them.
- Truncation from implicit length=8 on first assignment
- `proc sql` cartesian product warnings
-68
View File
@@ -1,68 +0,0 @@
---
name: sasjs-adapter
description: Frontend/Node integration with SAS backends using @sasjs/adapter — configuring the SASjs class, authentication (SAS 9, Viya, SASjs server), requests with input/output tables, file upload, and session management. Use when writing TypeScript/JavaScript that calls SAS services or jobs.
---
# @sasjs/adapter
`@sasjs/adapter` is the TypeScript library for calling SAS services/jobs from browsers or Node, with a unified API across three server types: `SAS9`, `SASVIYA`, `SASJS`.
## Basic setup
```ts
import SASjs from '@sasjs/adapter'
const sasjs = new SASjs({
serverUrl: 'https://sas.example.com',
serverType: 'SASVIYA', // SAS9 | SASVIYA | SASJS
appLoc: '/Public/app/myapp', // root folder of deployed services
contextName: 'SAS Job Execution compute context', // Viya only
debug: false
})
```
## Executing a request
```ts
const response = await sasjs.request('services/common/getdata', {
mytable: [{ col1: 'value', col2: 42 }] // input tables as JS arrays of objects
})
// response.result contains output tables sent back from SAS (_webout JSON)
```
- Input tables become SAS datasets via the `sasjs_tables` mechanism (work tables named after the JS keys).
- The SAS service must write JSON to `_webout` — conventionally with the `mp_jsonout` macro from @sasjs/core, wrapped in `proc stp`-style begin/end macros.
- Responses follow the `SASjsRequest`/`SASjsResponse` types; check `response.result` for tables and `response.log` where available.
## Authentication
- **SAS 9**: `sasjs.logIn(username, password)` (form-based against the stored process server). Session cookie is managed automatically.
- **Viya**: OAuth client/secret (client credentials grant) or authorization code flow; tokens are refreshed automatically. Configure via CLI (`sasjs add cred`) for Node usage.
- **SASJS server**: token-based auth against the sasjs/server API.
## Key classes / modules
- `SASjs` — main facade: `request()`, `logIn()/logOut()`, `uploadFile()`, `executeScript()`
- `SessionManager` — Viya compute session lifecycle
- `ContextManager` — Viya compute context selection
- `SASViyaApiClient` / `SAS9ApiClient` / `SASjsApiClient` — low-level per-platform clients (rarely needed directly)
- `file/` utilities — file upload to SAS (binary content handling)
## Tips
- Set `debug: true` to surface the SAS log in responses while developing.
- Always handle `response.status` / error responses — SAS-side errors (e.g. from `%mp_abort`) come back in the JSON, not necessarily as HTTP errors.
- For large payloads prefer CSV upload or streamed files over JSON input tables.
- Keep `appLoc` consistent with the `appLoc` in `sasjsconfig.json` used to deploy.
## Important: request() inputs are ALWAYS tables
Every key in the `data` object of `sasjs.request(path, data)` is serialized via the `sasjs_tables` CSV mechanism and arrives in SAS as a **work dataset named after the key** — even scalar values. You cannot pass ad-hoc macro variables this way; services must read inputs from the work table (e.g. `data _null_; set work.config; call symputx('rootdir', rootdir); run;`). Output column names in `response.result.<table>` come back UPPERCASE (SAS dataset semantics).
## Using the adapter without a bundler (zero-build / strict CSP frontends)
The package root `index.js` is a UMD bundle exposing a global `SASjs`. Pattern (from the minimal seed app):
1. `"prepare": "cp node_modules/@sasjs/adapter/index.js src/sasjs.js"` in package.json (runs on `npm i`).
2. `<script src="sasjs.js"></script>` before your app script.
3. Configure via a hidden custom element: `<sasjs serverType="SASJS" appLoc="/Public/app/myapp" debug="false"></sasjs>` and read attributes with `document.querySelector('sasjs')`. When the app is streamed by SAS itself, omit `serverUrl` — same-origin requests just work (CSP `default-src 'self'` safe).
-115
View File
@@ -1,115 +0,0 @@
---
name: sasjs-cli
description: Using the SASjs CLI (@sasjs/cli) to create, compile, build, deploy, run, and test SASjs projects against SAS 9, Viya, and SASjs server targets. Use for any `sasjs <command>` usage, CI/CD pipelines, target/auth config, sasjsconfig.json, service packs, or frontend streaming builds.
---
# @sasjs/cli
The SASjs CLI (`npm i -g @sasjs/cli`, invoked as `sasjs`) automates compiling, building, and deploying SAS projects. All commands support `-t <target>` to select a target from `sasjsconfig.json`.
## Targets and auth
- A **target** = `{ name, serverUrl, serverType, appLoc }`. `serverType` is one of `SAS9`, `SASVIYA`, `SASJS`.
- Credentials: `sasjs add cred` (or `.env` file). Viya uses client/secret **or** `sasjs auth login` (user/pass, no client/secret needed — see below); SAS 9 uses user/pass; SASJS server uses an access token.
- `sasjs context` manages Viya compute contexts; `sasjs add target` adds a new target.
## Core workflow
```
sasjs create myapp # scaffold a new app (templates available)
sasjs compile # gather macros/services/jobs into per-file build outputs (sasjsbuild/)
sasjs build # produce deployable artefacts: JSON + .sas per target
sasjs deploy # deploy compiled/built artefacts to the target server
sasjs cbd # compile + build + deploy in one step (-t viya etc.)
```
## Command reference
| Command | Purpose |
|---|---|
| `sasjs create / init` | Scaffold new app or add SASjs to existing repo |
| `sasjs compile` | Resolve dependencies (`<h4> SAS Macros </h4>` etc.) into `sasjsbuild/` |
| `sasjs build` | Create build JSON / service pack per target |
| `sasjs deploy` / `cbd` | Deploy to server (servicepack or direct) |
| `sasjs run <file.sas>` | Execute an arbitrary SAS file on the server, return log |
| `sasjs request <path>` | Execute a deployed service/job with input data (`-d`) |
| `sasjs job execute` | Run a deployed job |
| `sasjs flow execute` | Run a sequence of jobs with dependencies (CSV-defined flows) |
| `sasjs servicepack deploy` | Deploy from a JSON service pack |
| `sasjs web` | Build the frontend and stream it into the SAS web root (streamConfig) |
| `sasjs db` | Build database DDL/data load scripts from `sasjs/db` |
| `sasjs doc` | Generate doxygen documentation + lineage |
| `sasjs folder create/delete/move` | Manage folders in SAS metadata / SAS Drive |
| `sasjs fs` | File-system operations against the server (sync folders) |
| `sasjs test` | Execute tests defined in `sasjs/tests` with init/term programs |
| `sasjs lint` | Lint `.sas` files per `.sasjslint` config |
| `sasjs version` | Print/set version info |
## Conventions
- Test coverage is generated only from a `sasjs compile` (or `sasjs c`). It accepts a target (`-t <target>`), but nothing is deployed to that target — compilation and coverage are fully local/offline, so no server needs to be available or reachable. Missing macro dependencies (e.g. `mp_ds2csv.sas`) mean `@sasjs/core` isn't installed — run `npm i` first.
- Dependencies are declared in doxygen headers: `<h4> SAS Macros </h4>`, `<h4> SAS Files </h4>`, `<h4> SAS Folders </h4>`, and `@li item` entries — the CLI builds the dependency tree from these.
- `sasjs compile` output goes to the `sasjsbuild/` folder (git-ignore it); `sasjsresults/` holds test/run outputs.
- CI/CD: `sasjs cbd -t viya` is the standard deploy step; combine with `sasjs servicepack deploy` for artefact-based releases.
- Exit codes are non-zero on failure — safe for pipelines.
## Gotchas
- Run `npm i` before `sasjs cb` — macro dependency resolution needs `node_modules/@sasjs/core` present, and `@sasjs/core` (and `@sasjs/adapter` if used) must be listed in `package.json`.
- Credentials files are per-target: `.env.<targetname>` (e.g. `.env.server`) with `CLIENT`, `ACCESS_TOKEN`, `REFRESH_TOKEN`. Never commit them — gitignore `.env*`.
## Viya auth without a client/secret (`sasjs auth login`)
`sasjs auth login -t <target>` authenticates with a regular SAS username/password via the OAuth2 password grant against the built-in, secret-less `sas.cli` public client. No admin-registered OAuth client is needed — the fastest way to get `sasjs run`/`deploy` working on dev/demo estates. The password is never stored; the minted ACCESS_TOKEN/REFRESH_TOKEN pair is persisted to `.env.<target>` (local) or `~/.sasjsrc` (global) and verified via `/identities/users/@currentUser` (`Logged in as <id> (<name>)`). Bare `sasjs auth` is still an alias for `sasjs add cred`.
- Token expiry: the CLI silently refreshes via the stored refresh token (works with and without a client/secret), and re-persists the rotated pair — Viya refresh tokens are **single-use/rotating**, so this persistence is what keeps later invocations working. If refresh fails, re-run `sasjs auth login`.
- Some estates give `sas.cli` a short access-token TTL (e.g. 1h); a refresh on most invocations is normal.
- Opaque (non-JWT) tokens are treated as usable — the server is the authority on expiry.
- Limitations: local/LDAP accounts only (no SSO/SAML/MFA estates); password grant must be enabled for `sas.cli` (default on Viya 3.5+/4); ROPC is deprecated in OAuth 2.1 — use a registered client/secret for CI/production.
- `sasjs run` 403 on session creation = the account isn't authorised for the configured compute context — set `contextName: "SAS Studio compute context"` on the target. First run on a cold estate can take many minutes (compute pod spin-up) and may appear to hang.
- Self-signed estates: use `--insecure` on `auth login`, or configure `httpsAgentOptions` on the target.
## Viya streaming apps (streamConfig.streamWeb)
With `streamWeb: true`, `sasjs web`/`cbd -t viya` deploys the frontend **into SAS Files Service**: a streaming job at `<appLoc>/services/<streamServiceName>.html` serves `index.html`, and assets land in `<appLoc>/services/web/...`. At build time every asset/script/css URL in the HTML is rewritten to `/SASJobExecution?_FILE=<appLoc>/services/web/...` and the adapter config element (`<sasjs apploc=...>`) is stamped with the target `appLoc`.
**Consequence: the app only works when served from the exact `appLoc` it was deployed against.** If the streaming HTML is placed anywhere else (e.g. manually uploaded to a user home folder like `/Users/<id>/myapp/...`), all rewritten `/Public/app/...` asset links 404 and the adapter calls the wrong service paths — the page renders with no CSS/JS and no backend. Fix by redeploying. The original build (`sasjs cb`) has the apploc from the sasjsconfig.json - when the app is deployed as a SAS program, the supplied `%let apploc = ` (runtime value) is swapped with the `compiled_apploc` (build time value) to allow apps to be dynamically deployed to a given apploc at deploy time.
### Verifying a Viya deployment headlessly (no browser)
1. Get a token (password grant works out of the box with the built-in `sas.ec` client, empty secret):
`curl -X POST <server>/SASLogon/oauth/token -u 'sas.ec:' -d 'grant_type=password&username=U&password=P'`
2. Fetch the app: `GET /SASJobExecution/?_FILE=<appLoc>/services/<name>.html` with `Authorization: Bearer` → expect `200` and the full `index.html`. `401` = auth, anything else = not deployed there.
3. Fetch each asset the HTML references. Response tells you what's wrong:
- `200` with file content → asset deployed correctly.
- `202` + a tiny plain-text body (e.g. `Parameter Error\nFile error`) → **file does not exist at that Drive path** (classic appLoc-mismatch symptom). Note `_FILE` responses can be async: add `&_action=wait` to get content synchronously.
- Services: `POST /SASJobExecution/?_program=<appLoc>/services/<svc>&_action=wait` → `Parameter Error / Unable to get job definition` means the service was never deployed as a JES job (files on Drive alone are not enough — only `sasjs deploy`/`cbd`/`servicepack deploy` registers them).
### Talking to SAS — ALWAYS use the adapter or the CLI
When executing a SAS service or job for any purpose (debugging, reproduction, CI), **always go through the `@sasjs/adapter` or `sasjs` CLI** — never hand-roll curl against `SASJobExecution`. The adapter and CLI handle the things that are trivial to get wrong by hand: the input-data CSV format (space-separated `name:$format.` headers, CRLF, double-quoted special values, `%nrstr(...)` wrapping), the execution-mode routing (`_executionTasks=true` reads `sasjs<N>data` as **macro variables**, not file uploads), the `_debug`/`>>weboutBEGIN<<`/`>>weboutEND<<` wrapper parsing, the token refresh, and the `_contextname` URL param. A hand-built comma-separated CSV will read as blanks, silently skip guarded macro blocks, and send you down a rabbit hole of phantom bugs.
- Build input data as a JSON file: `{ "<table>": [{ "<col>": "<val>", ... }] }`, e.g. `{ "config": [{ "rootdir": "/export/...", "runastask": "true", "usecomputeapi": "null", "contextname": "Compute Reusable" }] }`.
- Run: `sasjs request '<appLoc>/services/common/<svc>' -t viya -d data.json -l <path>.log -o <path>.json`. The CLI hardcodes `debug: true`, so the `-l` flag always captures the full MPRINT/NOTE/`&syscc` log; `-o` saves the parsed webout. **Always pass `-l`** — reproducing a bug without the log means re-running the whole thing.
- The Viya Folders/JES REST APIs ARE fine to hit directly with curl (token + `Authorization: Bearer`) for folder/file/member management and for `GET /SASJobExecution/?_FILE=...` asset checks — just not for *executing your own services with input data*.
#### Adapter execution-context and input-data facts
- The adapter automatically appends `_contextname=<value>` as a **URL parameter** to every Viya JES request (you do NOT need to pass it yourself). BUT JES request params are **NOT auto-promoted to SAS macro variables** — `%symexist(_contextname)` is false inside the service. If the service needs the chosen context name (e.g. to stamp it into the streamed HTML), pass it in the **input data table** (a `contextname` column) and read it with `call symputx` — that is the reliable channel.
- The target's `contextName` in `sasjsconfig.json` decides which compute context the service runs under (and thus the `runAs` identity). The adapter's URL `_contextname` param is the same value. To reproduce a service under a batch/reusable context via `sasjs request`, set the target's `contextName` to that reusable context (e.g. `Compute Reusable`, runAs=sasbatch) — otherwise it runs as your own identity and write-test steps to batch-owned folders fail with `User does not have appropriate authorization level`.
- Adapter CSV format (so you can read the `NOTE: The infile ... is:` RULE in the log correctly): row 1 is the header with `name:$informat.` pairs **space-separated**; data rows are **comma-separated** (CRLF), with values double-quoted only if they contain a special char (`,`, `"`, tab, newline). The sasjs/core webout reader reads it back with `dsd` + `firstobs=2` + an `input <name>:$informat.;` statement derived from the header.
- `_executionTasks=true` (runAsTask) changes how input data arrives: as `sasjs<N>data` **macro variables** (chunked into `sasjs<N>data0..N`), NOT as `_WEBIN_FILE` uploads. The core webout `mv_webout` macro handles both, but it branches on `_EXECUTIONTASKS` — be aware when reading logs.
### Redeploying cleanly on Viya (the 409 Conflict problem)
Re-running `sasjs cbd`/`sasjs run viya.sas` against an **existing** appLoc often fails mid-deploy with `409 Conflict` (and an `mp_abort` → `abort cancel`): `mv_createfile` DELETEs the old file id then tries to recreate it, but when an intermediate **folder** already exists (e.g. `<appLoc>/services/web/js`) the recreate step conflicts and the whole deploy aborts — leaving a half-deployed app (services present, some assets missing). The `?recursive=true` folder DELETE also returns `409 You cannot delete the folder because it is not empty`, and individual member/folder DELETEs can return `403` even as the owner, so you cannot easily tear the tree down by hand.
The reliable workaround is to **move the top appLoc folder out of the way** and redeploy to the original path — the deploy creates a fresh folder tree with no conflicts, and the old folder is retained as a backup. This is far faster and more reliable than fighting per-member deletes, and is the recommended pre-deploy step for any non-CI redeploy on Viya. Two ways to move it:
- **Rename in place** (simplest): `PATCH /folders/folders/{id}` with `{"name":"<old>.bak.<ts>","version":2}` — frees the original name, keeps the old folder as a sibling backup.
- **MOVE into a backup parent** (tidier — keeps all backups in one place): create (once) a backup parent folder e.g. `/Users/<id>/macrodash-backups`, then `POST /folders/folders/{backupParentId}/members/{appLocFolderId}?action=move` moves the whole tree (with contents) into it, freeing the original path for the fresh deploy.
(On sasjs/server just `sasjs fs delete` the appLoc first — Drive there supports clean recursive deletes.)
JES applies its own CSP header when streaming (includes `unsafe-inline`/`unsafe-eval`); a strict CSP meta tag in the app's HTML still applies and is the one that matters for the app code.
-99
View File
@@ -1,99 +0,0 @@
---
name: sasjs-core
description: Standards and conventions for the @sasjs/core SAS macro library (mf_*, mp_*, mm*, ms_*, mv_* macros). Use when writing or editing SAS macros in a sasjs/core-style repo, picking an existing macro over reinventing one, or the sasjs/core build, lint, doxygen, and testing conventions.
---
# @sasjs/core — SAS Macro Library
@sasjs/core is an MIT-licensed library of production-quality SAS macros for SAS application development, portable across SAS 9 (meta), Viya, and SASjs server.
## Coding standards (mandatory)
- One macro per file; filename must match the macro name (lowercase, no spaces)
- Macro definitions must use parentheses: `%macro x();` not `%macro x;`
- Macro *calls* are NOT terminated with a semicolon: `%my_macro()` not `%my_macro();`
- All macro variables must be declared `%local` to prevent scope leakage
- Always use `mf_getuniquefileref` when assigning filerefs, and `mf_getuniquelibref` when assigning librefs (never hardcode or hand-roll unique references)
- 2-space indentation, no tabs, no trailing spaces, no invisible characters, max line length 300 (hard lint limit) but keep lines to 80 chars max where possible
- Every file must have a Doxygen header:
```sas
/**
@file
@brief One-line description of the macro
<h4> SAS Macros </h4>
@li mf_othermacro.sas
@param [in] paramname Description
@param [out] outparam Description
<h4> Related Macros </h4>
@li mp_related.sas
@version 9.4
@author Your Name
**/
```
## Folder / prefix conventions
| Folder | Prefix | Platform |
|---|---|---|
| `base/` | `mf_` (function-style), `mp_` (procedure-style) | All platforms |
| `meta/` | `mm_` | SAS 9 metadata |
| `metax/` | `mmx_` | SAS 9 metadata (OS command dependent) |
| `viya/` | `mv_` | Viya |
| `server/` | `ms_` | SASjs server |
| `xplatform/` | `mx_` | Runtime platform detection |
| `fcmp/`, `lua/`, `ddl/` | — | PROC FCMP functions, LUA wrappers, DDL |
Use `mf_` macros when the macro returns a value usable in an expression; use `mp_` for procedural macros that generate code/statements.
**Cross-suite rule:** `mp_` macros must never reference `mx_` macros. Platform dispatching (SAS 9 / Viya / SASjs server) belongs in the `mx_` suite, which delegates to `ms_`/`mv_`/PROC STP per platform. If an `mp_` macro seems to need platform-specific behaviour, the macro itself belongs in `xplatform/` as an `mx_` macro instead.
## Reuse before writing
Before writing a new macro, check the library for an existing one — common utilities already exist, e.g. `mp_abort` (the deprecated `mf_abort` is retained for backwards compatibility — don't use it in new code), `mf_existds`, `mf_existvar`, `mf_existfileref`, `mf_getuser`, `mp_jsonout` (SAS datasets → JSON for `_webout`), `mp_ds2ddl`, `mp_hashdataset`. Platform-specific variants exist under `meta/`, `viya/`, `server/` and are selected at compile time by the CLI.
## Aborting safely
Never invoke `%mp_abort` from inside an `%if/%else` block — as a procedural macro, the macro processor can continue executing statements after it before the abort takes effect. Use the `iftrue=` condition parameter instead:
```sas
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program
,msg=%str(Something went wrong)
)
```
When `%mp_abort` is called from within a `%include` block, SAS cannot exit cleanly (e.g. to `_webout`). Call `%mp_abort(mode=INCLUDE)` after the include (OUTSIDE any macro wrapper) — it checks `work.mp_abort_errds` for an abort status:
```sas
%mp_abort(mode=INCLUDE)
```
Note: `%include`s inside macros should be performed with `%mp_include()` so the `_SYSINCLUDEFILEDEVICE` indicator is set and the abort dataset (`work.mp_abort_errds`) is passed back to the calling program.
## Testing macros (mandatory conventions)
- **Always apply `%mp_assertscope` around the macro under test** to catch scope leakage (macro variables must stay `%local`):
```sas
%mp_assertscope(SNAPSHOT)
%mx_foo(args)
%mp_assertscope(COMPARE,
desc=Test 1: mx_foo does not leak scope,
outds=work.test_results
)
```
- Assertions go to `work.test_results` via `%mp_assert(iftrue=(...), desc=..., outds=work.test_results)`.
## Lint and build
- Run `sasjs lint` after every change; do not consider work done until it passes
- NEVER bump the version in `package.json` (semantic-release handles it)
- Do NOT edit generated files by hand: `all.sas`, `mc_*.sas`, the `lua/` wrappers, and `sasjsbuild/` outputs are produced by the CI build
- Markdown files: never hard-wrap; one paragraph per line
-103
View File
@@ -1,103 +0,0 @@
---
name: sasjs-framework
description: Building full SASjs applications — project structure, sasjsconfig.json, services/jobs/macros folders, multi-target (SAS 9 / Viya / SASjs server) configuration, streaming frontends, mocks and tests. Use when creating or modifying a SASjs app, editing sasjsconfig.json, or writing backend services returning JSON to a web frontend.
---
# SASjs Framework — Building SASjs Applications
A SASjs app = a web frontend (any framework: Angular, React, vanilla) + SAS backend code organised in a standard layout, compiled and deployed by `@sasjs/cli` to SAS 9, Viya, or SASjs server. Frontend talks to SAS via `@sasjs/adapter`; backend services return JSON via `_webout`.
## Standard project layout
```
sasjs/
sasjsconfig.json # project + target configuration
macros/ # project-specific macros (macroFolders)
services/ # web services called from the frontend
jobs/ # jobs (scheduled / flow / long-running)
programs/ # plain programs (initProgram, termProgram, utilities)
db/ # DDL + static data per library (sasjs db)
tests/ # tests run by `sasjs test`
mocks/ # mock responses for offline frontend dev (syncFolder)
doxy/ # extra doxygen content for `sasjs doc`
```
## sasjsconfig.json
Root config holds defaults; each entry in `targets[]` can override them. Key sections:
- `macroFolders`, `binaryFolders` — where the CLI finds macros/binaries
- `serviceConfig.serviceFolders` — service source folders; `initProgram` runs before every service (set up libnames, options)
- `jobConfig.jobFolders` — job source folders
- `programFolders` — programs compiled/deployed with the app
- `streamConfig` — `streamWeb: true` streams the built frontend into SAS so it is served by the platform itself (no separate web server needed); `webSourcePath` points at the frontend build output
- `syncFolder` — folder synced to the server (e.g. mocks)
- `testConfig` — init/term programs for `sasjs test`
- `targets[]` — per-environment overrides: `serverUrl`, `serverType` (`SAS9`/`SASVIYA`/`SASJS`), `appLoc` (deploy root, e.g. `/Public/app/myapp`), target-specific macroFolders (e.g. `targets/viya/macros_viya` for platform shims), `httpsAgentOptions`, `deployConfig`
The full JSON schema is bundled at `sasjsconfig-schema.json` next to this file — validate config changes against it. Reference it with `"$schema": "https://cli.sasjs.io/sasjsconfig-schema.json"`.
## Streamed frontend files on Viya (mime types)
When `streamWeb: true`, the CLI uploads the frontend (`index.html`, renamed per `streamServiceName`, plus css/js) to the Viya Files service using the `%mv_createfile` macro. That macro creates the file in a very particular way to ensure it streams correctly:
- POSTs to `/files/files` with the content type derived from the extension (`%mf_mimetype`)
- sets `typeDefName=file_html` (via `%mv_getViyaFileExtParms`) so the file is recognised as HTML
- sends `Content-Disposition` **without** `attachment` for HTML/SVG so it renders in the browser
**Never update a streamed frontend file in place** with a `filename filesrvc` fileref + data step rewrite — the Files service then treats it as a generic blob and the mime type is lost, so the app no longer streams (browser downloads it or shows raw text). To modify a streamed file at runtime (eg patching the compute `contextname` in the html), read it (a `filesrvc` fileref is fine for *reading*), write the modified content to a temp fileref, and **re-create the file with `%mv_createfile(path=..., name=..., inref=...)`** (it deletes the old file and re-POSTs with the correct mime type).
## Service contract (frontend ↔ SAS)
1. Adapter POSTs to `services/<folder>/<name>` with input tables (arrays of objects) → work datasets named after the JS keys.
2. Service SAS code runs after `initProgram`; it reads inputs, does work, and writes output JSON to `_webout`.
3. Conventional pattern using @sasjs/core macros:
```sas
/**
@file
@brief Example service returning data
<h4> SAS Macros </h4>
@li mp_jsonout.sas
@li mp_abort.sas
**/
/* validation / logic here */
%mp_jsonout(OPEN)
%mp_jsonout(OBJ,results,dslabel=results)
%mp_jsonout(CLOSE)
```
4. On error, abort cleanly with `%mp_abort(...)` (`mf_abort` is deprecated) so the adapter receives a structured error in the JSON, not a half-written response. Do **not** call `%mp_abort` inside an `%if/%else` block — the macro processor may keep executing beyond the abort. Use the conditional `iftrue=` parameter instead, e.g.:
```sas
%mp_abort(iftrue= (%mf_existds(work.results)=0)
,mac=&_program
,msg=%str(No results found)
)
```
If the abort happens inside a `%include` block, SAS cannot exit to `_webout` cleanly — after the include, call `%mp_abort(mode=INCLUDE)` (outside any macro wrapper), which checks `work.mp_abort_errds` for an abort status.
## Multi-target discipline
- Keep backend code platform-neutral in shared folders; put platform-specific shims in `targets/<name>/macros_*` folders and register them only on that target.
- Platform capability macros exist in @sasjs/core (`mm_*` metadata, `mv_*` Viya, `ms_*` server) — don't branch on server type by hand.
## Quality gates (follow the conventions of mature apps like Data Controller)
- Run `sasjs lint` after touching any `.sas` file; fix all warnings in files you touched.
- The linter enforces 2-space indentation everywhere, including continuation lines inside `/* ... */` block comments — never align comment text with 3+ spaces.
- Add tests and run `sasjs test` for backend logic changes. When testing macros, always wrap the macro under test with `%mp_assertscope(SNAPSHOT)` / `%mp_assertscope(COMPARE, ...)` to catch macro-variable scope leakage, and wrap any platform-branching code in `%macro` wrappers (no open conditional macro code in test programs).
- Provide mocks in `sasjs/mocks` so the frontend can be developed without a live SAS server.
- Never auto-commit or bump versions; releases are pipeline-driven (conventional commits).
- Markdown files: no hard wrapping — one paragraph per line.
- Apps must work offline/on-prem: no external CDN assets in the frontend bundle.
## Reference implementations
Look at existing apps for patterns: folder layouts, `sasjsconfig.json` multi-target setups, service structure, streaming builds, and test/mock conventions, eg:
* https://git.datacontroller.io/dc/dc
* https://github.com/sasjs/react-seed-app
* https://github.com/sasjs/macro-dash
@@ -1,966 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/sasjs/utils/blob/main/src/types/sasjsconfig-schema.json",
"type": "object",
"title": "SASjs Config File",
"description": "The SASjs Config file provides the settings and structure for your SASjs project. ",
"default": {},
"examples": [
{
"macroFolders": ["macros"],
"programFolders": ["programs"],
"binaryFolders": ["binaries"],
"defaultTarget": "viya",
"targets": [
{
"name": "viya",
"serverType": "SASVIYA",
"serverUrl": "https://sas.sasjs.com",
"appLoc": "/Public/app",
"contextName": "SAS Job Execution compute context",
"deployConfig": {
"deployServicePack": true,
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
},
"serviceConfig": {
"serviceFolders": ["targets/viya/services/admin"],
"initProgram": "build/serviceinit.sas",
"termProgram": "build/serviceinit.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"jobConfig": {
"jobFolders": [],
"initProgram": "",
"termProgram": "",
"macroVars": {}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "webv",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/viya/macros"],
"programFolders": [],
"binaryFolders": ["binaries"]
},
{
"name": "sas9",
"serverType": "SAS9",
"serverUrl": "https://sas.sasjs.com:7980",
"appLoc": "/User Folders/&sysuserid/My Folder",
"serverName": "Foundation",
"repositoryName": "SASApp",
"buildConfig": {
"buildOutputFileName": "mysas9deploy.sas",
"initProgram": "",
"termProgram": "",
"macroVars": {}
},
"deployConfig": {
"deployScripts": ["build/deploysas9.sh"],
"deployServicePack": false
},
"serviceConfig": {
"serviceFolders": ["targets/sas9/services/admin"],
"initProgram": "",
"termProgram": "build/servicetermother.sas",
"macroVars": {}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "web9",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/sas9/macros"],
"programFolders": [],
"binaryFolders": ["binaries"]
}
]
}
],
"properties": {
"binaryFolders": {
"$id": "#/properties/binaryFolders",
"type": "array",
"title": "The binaryFolders array",
"description": "These local folders are searched for Binary Files when running sasjs compile. Folders are relative to the sasjs/sasjsconfig.json file.",
"examples": [["binaries", "../../more_binaries"]]
},
"sasjsBuildFolder": {
"$id": "#/properties/sasjsBuildFolder",
"type": "string",
"title": "sasjsBuildFolder",
"description": "The name of the folder containing the compiled output. The `sasjs build` command will take all of the subfolders here as inputs to create the build pack. By default this will be named `sasjsbuild`. In global, the default is `~/.sasjsbuild`.",
"default": "sasjsbuild",
"examples": ["sasjsbuild", ".sasjsbuild"]
},
"sasjsResultsFolder": {
"$id": "#/properties/sasjsResultsFolder",
"type": "string",
"title": "sasjsResultsFolder",
"description": "The name of the folder containing the output (eg logs, ODS output) from `sasjs run`. By default this will be named `sasjsresults`. In global, the default is `~/.sasjsresults`.",
"default": "sasjsresults",
"examples": ["sasjsresults", ".sasjsresults"]
},
"defaultTarget": {
"$id": "#/properties/defaultTarget",
"type": "string",
"title": "Default Target",
"description": "If a target is not specified, this target is used by default. The default target must exist in the (local) targets array.",
"default": "viya",
"examples": ["viya"]
},
"docConfig": {
"$id": "#/properties/docConfig",
"type": "object",
"title": "The docConfig schema",
"description": "SASjs uses doxygen to auto-generate HTML documentation using the headers in your SAS programs, macros, services & jobs. For more info, see [https://cli.sasjs.io/doc](https://cli.sasjs.io/doc). Some properties are taken from package.json (such as the project Name).",
"default": {
"dataControllerUrl": "https://mysasserver.com/web/datacontroller/#",
"enableLineage": true,
"doxyContent": {
"readMe": "../../README.md"
}
},
"examples": [
{
"displayMacroCore": true,
"outDirectory": "/some/output/directory",
"dataControllerUrl": "https://mysasserver.com/web/datacontroller/#",
"enableLineage": true,
"doxyContent": {
"readMe": "../../my/custom/homepage.md",
"path": "my/custom/doxy/folder"
}
}
],
"properties": {
"displayMacroCore": {
"$id": "#/properties/docConfig/properties/displayMacroCore",
"type": "boolean",
"title": "The displayMacroCore docConfig option",
"description": "The CLI will autocompile macro dependencies that exist in the SASjs Macro Core library. These will also show in the documentation under 'node_modules'. If you'd prefer not to show these in the rendered docs, set this value to false.",
"default": true,
"examples": [true]
},
"outDirectory": {
"$id": "#/properties/docConfig/properties/outDirectory",
"type": "string",
"title": "The outDirectory docConfig option",
"description": "The location to which the generated HTML SAS documentation is written. If missing, or left blank, the files will be written to the `sasjsbuild/doc` directory (default behaviour).",
"default": "sasjsbuild/doc",
"examples": ["/my/preferred/docs/directory"]
},
"dataControllerUrl": {
"$id": "#/properties/docConfig/properties/dataControllerUrl",
"type": "string",
"title": "The dataControllerUrl docConfig option",
"description": "Provide the full URL to Data Controller so that `sasjs doc` can link the lineage diagram directly to the table viewer in [Data Controller](https://datacontroller.io).\nIf left blank, or undefined, no links will be generated.",
"default": "",
"examples": ["https://yourserver.co.uk/dcviya/#"]
},
"enableLineage": {
"$id": "#/properties/docConfig/properties/enableLineage",
"type": "boolean",
"title": "Enable Lineage",
"description": "If true, sasjs doc will generate and display Data Lineage from Jobs and Services.",
"default": true
},
"doxyContent": {
"$id": "#/properties/docConfig/properties/doxyContent",
"type": "object",
"title": "doxyContent",
"description": "Configuration of the Doxyfile variables",
"default": {},
"examples": [
{
"favIcon": "favicon.ico",
"footer": "new_footer.html",
"header": "new_header.html",
"layout": "DoxygenLayout.xml",
"logo": "logo.png",
"readMe": "../../README.md",
"stylesheet": "new_stylesheet.css",
"path": "sasjs/doxy"
}
],
"properties": {
"favIcon": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/favIcon",
"type": "string",
"title": "Doxygen favicon",
"description": "The favicon used in the doxygen documentation",
"default": "favicon.ico"
},
"footer": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/footer",
"type": "string",
"title": "Doxygen footer",
"description": "The footer HTML file used in the doxygen documentation",
"default": "new_footer.html"
},
"header": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/header",
"type": "string",
"title": "Doxygen header",
"description": "The header HTML file used in the doxygen documentation",
"default": "new_header.html"
},
"layout": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/layout",
"type": "string",
"title": "Doxygen Layout XML File",
"description": "The layout XML file used to build the doxygen documentation",
"default": "DoxygenLayout.xml"
},
"logo": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/logo",
"type": "string",
"title": "Doxygen Logo",
"description": "The logo file used by doxygen",
"default": "logo.png"
},
"readMe": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/readMe",
"type": "string",
"title": "Doxygen Homepage",
"description": "The file used to generate the doxygen homepage (defaults to the readme.md)",
"default": "../../README.md"
},
"stylesheet": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/stylesheet",
"type": "string",
"title": "Doxygen CSS",
"description": "The CSS file used to extend Doxygen",
"default": "new_stylesheet.css"
},
"path": {
"$id": "#/properties/docConfig/properties/doxyContent/properties/path",
"type": "string",
"title": "Doxygen Path",
"description": "The path to the Doxygen configuration files (relative to sasjs/sasjsconfig.json)",
"default": "sasjs/doxy"
}
}
}
}
},
"httpsAgentOptions": {
"$id": "#/properties/httpsAgentOptions",
"type": "object",
"title": "httpsAgentOptions",
"description": "Configure https agent by setting all supported attribute such as `key`, `cert`, `ca`, `rejectUnauthorized` and `requestCert`",
"examples": [
{
"allowInsecureRequests": false,
"caPath": "path/to/caFile",
"keyPath": "path/to/keyFile",
"certPath": "path/to/certFile",
"requestCert": false,
"rejectUnauthorized": true
}
],
"properties": {
"allowInsecureRequests": {
"$id": "#/properties/httpsAgentOptions/properties/allowInsecureRequests",
"type": "boolean",
"title": "allowInsecureRequests",
"description": "If you are having certificate errors connecting to SAS, that cannot be properly resolved, try setting this value to true. This option only has an effect if rejectUnauthorized is not present.",
"default": false,
"examples": [true, false]
},
"caPath": {
"$id": "#/properties/httpsAgentOptions/properties/caPath",
"type": "string",
"title": "caPath",
"description": "Optionally override the trusted CA certificates. Default is to trust the well-known CAs curated by Mozilla. Mozilla's CAs are completely replaced when CAs are explicitly specified using this option.",
"examples": ["path/to/caFile"]
},
"keyFile": {
"$id": "#/properties/httpsAgentOptions/properties/keyFile",
"type": "string",
"title": "keyFile",
"description": "Private keys in PEM format. PEM allows the option of private keys being encrypted. Encrypted keys will be decrypted with options.passphrase. Multiple keys using different algorithms can be provided either as an array of unencrypted key strings or buffers, or an array of objects in the form {pem: <string|buffer>[, passphrase: <string>]}. The object form can only occur in an array. object.passphrase is optional. Encrypted keys will be decrypted with object.passphrase if provided, or options.passphrase if it is not.",
"examples": ["path/to/keyFile"]
},
"certFile": {
"$id": "#/properties/httpsAgentOptions/properties/certFile",
"type": "string",
"title": "certFile",
"description": "Cert chains in PEM format. One cert chain should be provided per private key. Each cert chain should consist of the PEM formatted certificate for a provided private key, followed by the PEM formatted intermediate certificates (if any), in order, and not including the root CA (the root CA must be pre-known to the peer, see ca). When providing multiple cert chains, they do not have to be in the same order as their private keys in key. If the intermediate certificates are not provided, the peer will not be able to validate the certificate, and the handshake will fail.",
"examples": ["path/to/certFile"]
},
"requestCert": {
"$id": "#/properties/httpsAgentOptions/properties/requestCert",
"type": "boolean",
"title": "requestCert",
"description": "If true the server will request a certificate from clients that connect and attempt to verify that certificate. Defaults to false.",
"default": false,
"examples": [true, false]
},
"rejectUnauthorized": {
"$id": "#/properties/httpsAgentOptions/properties/rejectUnauthorized",
"type": "boolean",
"title": "rejectUnauthorized",
"description": "If true the server will reject any connection which is not authorized with the list of supplied CAs. This option only has an effect if requestCert is true.",
"default": true,
"examples": [true, false]
}
}
},
"buildConfig": {
"$id": "#/properties/buildConfig",
"type": "object",
"title": "buildConfig",
"description": "Dictates which files get compiled into the build program (.sas), used to deploy services into SAS 9 or Viya environments (without a client/secret). You may use this config to include build specific macros, programs or macro variables - which is run a single time, on deployment - for things like database creation, or exporting a SAS 9 SPK after service creation.",
"examples": [
{
"initProgram": "build/buildinit.sas",
"termProgram": "build/buildterm.sas",
"macroVars": {
"name": "value",
"numvar": "42"
},
"buildOutputFileName": "buildpack.sas"
}
],
"properties": {
"buildOutputFileName": {
"$id": "#/properties/buildConfig/properties/buildOutputFileName",
"type": "string",
"title": "buildOutputFileName",
"description": "The name of the generated .sas program, which can be used to deploy the app using only SAS Studio. By default, this will be the name of the target.",
"examples": ["viya.sas", "sas9.sas"]
},
"initProgram": {
"$id": "#/properties/buildConfig/properties/initProgram",
"type": "string",
"title": "The buildConfig initProgram",
"description": "The path to a .sas program that will be inserted at the start of the build .sas program (created when running `sasjs build`).",
"default": "sasjs/buildinit.sas"
},
"termProgram": {
"$id": "#/properties/buildConfig/properties/termProgram",
"type": "string",
"title": "The buildConfig termProgram",
"description": "The path to a .sas program that will be inserted at the end of the build .sas program (created when running `sasjs build`).",
"default": "sasjs/buildterm.sas"
},
"macroVars": {
"$id": "#/properties/buildConfig/properties/macroVars",
"type": "object",
"title": "The buildConfig macro variables",
"description": "A series of name value pairs that will be turned into SAS macro variables in the build .sas program (generated when running `sasjs build`). The example provided will generate sas code as follows:\n```\n%let name=value;\n%let numvar=42;\n```",
"default": {},
"examples": [
{
"name": "value",
"numvar": "42"
}
]
}
}
},
"deployConfig": {
"$id": "#/properties/deployConfig",
"type": "object",
"title": "The deployConfig schema",
"description": "The deployConfig object enables settings that relate to the deployment of a SAS app - be that Viya, or SAS 9, or a pure Base environment.",
"default": {},
"examples": [
{
"deployScripts": ["build/deployscript.sh"]
},
{
"deployScripts": ["build/deployscript.sh"],
"deployServicePack": true
}
],
"properties": {
"deployScripts": {
"$id": "#/properties/deployConfig/properties/deployScripts",
"type": "array",
"title": "The deployConfig deployScripts array",
"description": "These scripts are executed when running `sasjs deploy`. If the file is a .sas file, it is executed on the SAS server (Viya only). Otherwise it is executed locally. These scripts are run AFTER the deployment of the servicepack, if `deployServicePack:true` (Viya only).",
"default": [],
"examples": [["build/deployscript.sh", "build/myprogram.sas"]]
},
"deployServicePack": {
"$id": "#/properties/deployConfig/properties/deployServicePack",
"type": "boolean",
"title": "The deployConfig deployServicePack flag",
"description": "If set to `true` the json pack produced by `sasjs build` will be auto-deployed to the `appLoc` of the specified target (creating all jobs and services in the SAS folder tree). Currently only Viya is supported for this flag.",
"default": false
}
}
},
"serviceConfig": {
"$id": "#/properties/serviceConfig",
"type": "object",
"title": "The serviceConfig schema",
"description": "The serviceConfig object defines how SASjs web services are compiled. Web services differ from jobs in that they include some fixed pre-code (eg the macros to stream out the result json).",
"default": {},
"examples": [
{
"serviceFolders": ["services/common", "services/admin"],
"initProgram": "build/serviceinit.sas",
"termProgram": "build/serviceterm.sas",
"macroVars": {
"mac1": "value",
"mac2": "42"
}
}
],
"required": [],
"properties": {
"serviceFolders": {
"$id": "#/properties/serviceConfig/properties/serviceFolders",
"type": "array",
"title": "The serviceConfig serviceFolders array",
"description": "When running `sasjs compile`, all programs in the folders defined in this array are compiled and placed into same-named folders under `sasjsbuild/services`. They will be compiled as services (so, with the service pre-code). Folders can be absolute, or relative to the `sasjs` folder.",
"default": [],
"examples": [["services/common", "services/admin"]]
},
"initProgram": {
"$id": "#/properties/serviceConfig/properties/initProgram",
"type": "string",
"title": "The serviceConfig initProgram",
"description": "The serviceConfig `initProgram` is a .sas file that is inserted at the start of every SAS service (after compiled macros and any `macroVars`, and before the service itself). ",
"default": "",
"examples": ["build/serviceinit.sas"]
},
"termProgram": {
"$id": "#/properties/serviceConfig/properties/termProgram",
"type": "string",
"title": "The serviceConfig termProgram",
"description": "The serviceConfig termProgram is inserted at the end of every service as part of `sasjs compile`.",
"default": "",
"examples": ["build/serviceterm.sas"]
},
"macroVars": {
"$id": "#/properties/serviceConfig/properties/macroVars",
"type": "object",
"title": "The `serviceConfig` macroVars",
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every service. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
"default": {},
"examples": [
{
"mac1": "value",
"mac2": "42"
}
]
}
}
},
"jobConfig": {
"$id": "#/properties/jobConfig",
"type": "object",
"title": "The jobConfig schema",
"description": "The jobConfig object defines how SASjs Jobs are compiled. ",
"default": {},
"examples": [
{
"jobFolders": ["jobs/extract", "jobs/load"],
"initProgram": "jobs/jobinit.sas",
"termProgram": "jobs/jobterm.sas",
"macroVars": {
"mac1": "value",
"mac2": "42"
}
}
],
"properties": {
"jobFolders": {
"$id": "#/properties/jobConfig/properties/jobFolders",
"type": "array",
"title": "The jobConfig jobFolders array",
"description": "When running `sasjs compile`, all programs in the local folders defined in this array are compiled and placed into same-named folders under `sasjsbuild/jobs`. Folders can be absolute, or relative to the local project `/sasjs` folder.",
"default": [],
"examples": [["jobs/extract", "jobs/transform", "jobs/load"]]
},
"initProgram": {
"$id": "#/properties/jobConfig/properties/initProgram",
"type": "string",
"title": "The jobConfig initProgram",
"description": "The jobConfig `initProgram` is a local .sas file that is inserted at the start of every SAS Job (after compiled macros and any `macroVars`, and before the Job itself). ",
"default": "",
"examples": ["jobs/jobinit.sas"]
},
"termProgram": {
"$id": "#/properties/jobConfig/properties/termProgram",
"type": "string",
"title": "The jobConfig termProgram",
"description": "The jobConfig termProgram is inserted at the end of every Job as part of `sasjs compile`.",
"default": "",
"examples": ["jobs/jobterm.sas"]
},
"macroVars": {
"$id": "#/properties/jobConfig/properties/macroVars",
"type": "object",
"title": "The `jobConfig` macroVars",
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every Job. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
"default": {},
"examples": [
{
"mac1": "value",
"mac2": "42"
}
]
}
}
},
"streamConfig": {
"$id": "#/properties/streamConfig",
"type": "object",
"title": "streamConfig",
"description": "SASjs allows a local web app to be compiled such that all html, css, javascript, and other assets such as png or mp4 are converted into web services and streamed directly from SAS.\n\nThis approach is convenient as it bypasses the need to deploy to a web server. ",
"default": {},
"examples": [
{
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "webv",
"webSourcePath": "dist",
"streamLogo": "logo.png"
}
],
"required": ["streamWeb"],
"properties": {
"assetPaths": {
"$id": "#/properties/streamConfig/properties/assetPaths",
"type": "array",
"title": "The streamConfig assetPaths array",
"description": "An array of local folders. All assets placed in these folders are converted into web services - example file types could be png, svg, mp3, mp4, excel - anything really.",
"default": [],
"examples": [["/myassets"]]
},
"streamLogo": {
"$id": "#/properties/streamConfig/properties/streamLogo",
"type": "string",
"title": "Icon shown in AppStream (sasjs/server)",
"description": "Provide the location of a square image, under the webSourcePath. Used as the display icon on the appStream page.",
"default": "logo.png",
"examples": ["logo.png", "favicon.ico"]
},
"streamWeb": {
"$id": "#/properties/streamConfig/properties/streamWeb",
"type": "boolean",
"title": "The streamConfig streamWeb flag",
"description": "When set to `true`, frontend files saved in the `webSourcePath` will be converted to streaming services in the `streamWebFolder` in SAS.",
"default": false,
"examples": [true]
},
"streamWebFolder": {
"$id": "#/properties/streamConfig/properties/streamWebFolder",
"type": "string",
"title": "The streamConfig streamWebFolder",
"description": "This is the target SAS folder (relative to the appLoc) where the compiled web assets will be created.",
"default": "webv"
},
"webSourcePath": {
"$id": "#/properties/streamConfig/properties/webSourcePath",
"type": "string",
"title": "The webSourcePath schema",
"description": "Active when `streamConfig` is `true`. Is the source (or build, or dist) LOCAL folder, relative to the `sasjs` folder, which contains the frontend to be deployed. All assets (PNG, JS, CSS, HTML etc) are taken from here and converted to streaming services in `streamWebFolder`. In Viya and SAS 9, any relative URLS will be modified such that the links still work and the assets still load.",
"default": "dist",
"examples": ["dist", "build"]
},
"streamServiceName": {
"$id": "#/properties/streamConfig/properties/streamServiceName",
"type": "string",
"title": "streamServiceName Schema",
"description": "The name of the service containing the `index.html` for a streaming web app. Defaults to `clickme` and is always deployed under the appLoc/services SAS Folder.",
"default": "clickme.sas"
}
}
},
"testConfig": {
"$id": "#/properties/testConfig",
"type": "object",
"title": "testConfig",
"description": "Create tests for Macros, Services & Jobs by simply adding a '.test.sas' extension. ",
"default": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"properties": {
"initProgram": {
"$id": "#/properties/testConfig/properties/initProgram",
"type": "string",
"title": "The jobConfig initProgram",
"description": "The testConfig `initProgram` is a local .sas file that is inserted at the start of every Test (after compiled macros and any `macroVars`, and before the Test itself). ",
"default": "",
"examples": ["sasjs/tests/testinit.sas"]
},
"termProgram": {
"$id": "#/properties/testConfig/properties/termProgram",
"type": "string",
"title": "The jobConfig termProgram",
"description": "The testConfig termProgram is inserted at the end of every Test as part of `sasjs compile`.",
"default": "",
"examples": ["jobs/jobterm.sas"]
},
"macroVars": {
"$id": "#/properties/testConfig/properties/macroVars",
"type": "object",
"title": "The `jobConfig` macroVars",
"description": "This object allows `sasjs compile` to insert specific macro variables at the start of every Test. In this case, the code generated would be:\n```\n%let mac1=value;\n%let mac2=42;\n```",
"default": {},
"examples": [
{
"mac1": "value",
"mac2": "42"
}
]
},
"testSetUp": {
"$id": "#/properties/testConfig/properties/testsetup",
"type": "string",
"title": "The jobConfig testSetUp",
"description": "This program is the first to execute as part of 'sasjs test'. It does not contain the testInit, testTerm or macroVariables. It IS compiled.",
"default": "sasjs/tests/testsetup.sas",
"examples": ["sasjs/tests/testsetup.sas"]
},
"testTearDown": {
"$id": "#/properties/testConfig/properties/testTearDown",
"type": "string",
"title": "The jobConfig testTearDown",
"description": "The last program to execute as part of 'sasjs test'.",
"default": "sasjs/tests/testteardown.sas",
"examples": ["sasjs/tests/testteardown.sas"]
}
}
},
"macroFolders": {
"$id": "#/properties/macroFolders",
"type": "array",
"title": "The macroFolders array",
"description": "These local folders are searched for SAS Macros when running `sasjs compile`. Folders are relative to the `sasjs/sasjsconfig.json` file.",
"default": [],
"examples": [["macros", "../../more_macros"]]
},
"programFolders": {
"$id": "#/properties/programFolders",
"type": "array",
"title": "The programFolders array",
"description": "These local folders are searched for SAS Programs when running `sasjs compile`. Folders are relative to the `sasjs/sasjsconfig.json` file.",
"default": [],
"examples": [["programs", "../../more_programs"]]
},
"syncFolder": {
"$id": "#/properties/syncFolder",
"type": "string",
"title": "Sync Folder",
"description": "The contents of this folder are simply copied to the sasjsbuild directory AFTER the rest of the project is compiled. Useful for synchronising random / generic content with SAS logical folders.",
"default": "sasjs/static_files"
},
"syncDirectories": {
"$id": "#/properties/syncDirectories",
"type": "array",
"title": "syncDirectories",
"description": "Maps the local filesystem to remote (SAS) physical directories.",
"examples": [
[
{
"local": "C:\\temp\\local\\fs1",
"remote": "/opt/data/fs1"
},
{
"local": "C:\\temp\\elsewhere",
"remote": "/opt/somewhere"
}
]
]
},
"targets": {
"$id": "#/properties/targets",
"type": "array",
"title": "The targets array",
"description": "A target is an alias for a deployment location, and includes at a minimum, the `serverUrl`, `serverType` and `appLoc`. This array allows multiple targets to be defined (eg dev / test / prod). Any properties defined here will override same-named properties in the sasjsconfig root.",
"default": [],
"examples": [
[
{
"name": "sas9target",
"serverType": "SAS9",
"serverUrl": "https://mysas9server",
"appLoc": "/Shared Folders/myApp"
}
],
[
{
"name": "viya",
"serverType": "SASVIYA",
"serverUrl": "https://sas.sasjs.com",
"appLoc": "/Public/app",
"contextName": "SAS Job Execution compute context",
"buildConfig": {
"buildOutputFileName": "myviyadeploy.sas",
"initProgram": "build/buildinitviya.sas",
"termProgram": "targets/viya/viyabuildterm.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"deployConfig": {
"deployServicePack": true,
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
},
"serviceConfig": {
"serviceFolders": ["targets/viya/services/admin"],
"initProgram": "build/serviceinit.sas",
"termProgram": "build/serviceinit.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "webv",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/viya/macros"]
},
{
"name": "sas9",
"serverType": "SAS9",
"serverUrl": "https://sas.sasjs.com:7980",
"appLoc": "/User Folders/&sysuserid/My Folder",
"serverName": "Foundation",
"repositoryName": "SASApp",
"buildConfig": {
"buildOutputFileName": "mysas9deploy.sas",
"initProgram": "",
"termProgram": "",
"macroVars": {}
},
"deployConfig": {
"deployScripts": ["build/deploysas9.sh"],
"deployServicePack": false
},
"serviceConfig": {
"serviceFolders": ["targets/sas9/services/admin"],
"initProgram": "",
"termProgram": "build/servicetermother.sas",
"macroVars": {}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "web9",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/sas9/macros"],
"programFolders": []
}
]
],
"items": {
"$id": "#/properties/targets/items",
"anyOf": [
{
"$id": "#/properties/targets/items/anyOf/0",
"type": "object",
"title": "SASjs Targets",
"description": "A target provides the configuration specific to a particular deployment, eg DEV / TEST, or SAS9 / SASVIYA.",
"default": {},
"examples": [
{
"name": "viya",
"serverType": "SASVIYA",
"serverUrl": "https://sas.sasjs.com",
"appLoc": "/Public/app",
"contextName": "SAS Job Execution compute context",
"buildConfig": {
"buildOutputFileName": "myviyadeploy.sas",
"initProgram": "build/buildinitviya.sas",
"termProgram": "targets/viya/viyabuildterm.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"deployConfig": {
"deployServicePack": true,
"deployScripts": ["sasjsbuild/myviyadeploy.sas"]
},
"serviceConfig": {
"serviceFolders": ["targets/viya/services/admin"],
"initProgram": "build/serviceinit.sas",
"termProgram": "build/serviceinit.sas",
"macroVars": {
"name": "viyavalue",
"extravar": "this too"
}
},
"jobConfig": {
"jobFolders": [],
"initProgram": "",
"termProgram": "",
"macroVars": {}
},
"streamConfig": {
"assetPaths": [],
"streamWeb": false,
"streamWebFolder": "webv",
"webSourcePath": "dist"
},
"testConfig": {
"initProgram": "sasjs/tests/testinit.sas",
"termProgram": "sasjs/tests/testterm.sas",
"macroVars": {
"testVar": "testValue"
},
"testSetUp": "sasjs/tests/testsetup.sas",
"testTearDown": "sasjs/tests/testteardown.sas"
},
"macroFolders": ["targets/viya/macros"],
"programFolders": []
}
],
"required": ["name", "serverType", "appLoc"],
"properties": {
"name": {
"$id": "#/properties/targets/items/anyOf/0/properties/name",
"type": "string",
"title": "Target name property",
"description": "A target name can only contain alphanumeric characters and dashes. It cannot contain spaces. It is used as the alias when referencing the target using the `-t` attribute in many of the SASjs commands.",
"default": "",
"examples": ["viya"]
},
"serverType": {
"$id": "#/properties/targets/items/anyOf/0/properties/serverType",
"type": "string",
"title": "The Target serverType",
"description": "The serverType can be either SAS9, SASVIYA or SASJS.",
"default": "",
"examples": ["SASVIYA"]
},
"appLoc": {
"$id": "#/properties/targets/items/anyOf/0/properties/appLoc",
"type": "string",
"title": "The Target appLoc",
"description": "The appLoc provides the root SAS folder location under which all jobs and services are deployed and executed. The SAS folder could be metadata in SAS 9, or SAS Drive in Viya. ",
"default": "",
"examples": ["/Public/app"]
},
"binaryFolders": {
"$ref": "#/properties/binaryFolders"
},
"buildConfig": {
"$ref": "#/properties/buildConfig"
},
"contextName": {
"$id": "#/properties/targets/items/anyOf/0/properties/contextName",
"type": "string",
"title": "The Target contextName",
"description": "The name of the compute context used to execute SAS code. The context determines the way in which the SAS session is spawned (eg user credentials, autoexec code, system options etc).\nContexts can be created / modified / deleted using the `sasjs context` command.",
"default": "SAS Job Execution compute context",
"examples": ["SAS Job Execution compute context"]
},
"deployConfig": {
"$ref": "#/properties/deployConfig"
},
"httpsAgentOptions": {
"$ref": "#/properties/httpsAgentOptions"
},
"serverUrl": {
"$id": "#/properties/targets/items/anyOf/0/properties/serverUrl",
"type": "string",
"title": "The Target serverUrl",
"description": "The serverUrl is the location to which the app is deployed, and against which any server based operations are performed. If SAS is served from a particular port, that port should also be included here.",
"default": "",
"examples": [
"https://sas.sasjs.com",
"https://sas.sasjs.com:8080"
]
},
"serviceConfig": {
"$ref": "#/properties/serviceConfig"
},
"jobConfig": {
"$ref": "#/properties/jobConfig"
},
"docConfig": {
"$ref": "#/properties/docConfig"
},
"streamConfig": {
"$ref": "#/properties/streamConfig"
},
"syncDirectories": {
"$ref": "#/properties/syncDirectories"
},
"syncFolder": {
"$ref": "#/properties/syncFolder"
},
"testConfig": {
"$ref": "#/properties/testConfig"
},
"macroFolders": {
"$ref": "#/properties/macroFolders"
},
"programFolders": {
"$ref": "#/properties/programFolders"
},
"sasjsBuildFolder": {
"$ref": "#properties/sasjsBuildFolder"
},
"sasjsResultsFolder": {
"$ref": "#properties/sasjsResultsFolder"
}
}
}
]
}
}
}
}
-83
View File
@@ -1,83 +0,0 @@
---
name: sasjs-server
description: Installing, configuring, and running @sasjs/server — the open-source NodeJS wrapper around the SAS binary that provides a REST API, filesystem (SASjs Drive), Stored Program execution, and web app streaming. Covers desktop vs server modes, runtimes (SAS/JS/Python/R), env vars, auth (tokens, LDAP), and mock servers. Use when deploying, troubleshooting, or developing against sasjs/server.
---
# @sasjs/server
SASjs Server is an open-source NodeJS wrapper for calling the SAS binary executable. It runs on a real SAS server or a local desktop and provides:
- A filesystem (SASjs Drive) for storing SAS programs and content
- Execution of Stored Programs from a URL (equivalent to SAS 9 Stored Processes / Viya Jobs)
- Web app streaming (serve frontend apps straight from SAS content)
- A REST API with Swagger docs
- Portability: apps built for SASjs Server deploy unchanged to SAS 9 / Viya via @sasjs/cli
## Modes
- **Desktop mode** (`MODE=desktop`, default): single-user, no authentication, no database. CORS enabled by default.
- **Server mode** (`MODE=server`): multi-user with authentication, requires a database (`DB_CONNECT`, `DB_TYPE=mongodb|cosmos_mongodb`). CORS disabled by default — configure `WHITELIST` if enabling.
## Installation
Download the relevant zip from GitHub releases and run the packaged executable (`api-linux`, etc.):
```bash
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip && ./api-linux
```
On first run it prompts (unless set as env vars) for the SAS executable path and the filesystem location for Stored Programs/temp files. Docker is also supported (`DockerfileApi`, docker-compose files in the repo).
## Configuration via environment variables
Set in `/etc/environment`, exported, prepended to the command, or in a `.env` file alongside the executable. Key variables:
| Variable | Purpose |
|---|---|
| `MODE` | `desktop` (default) or `server` |
| `SAS_PATH` | Path to `sas.exe` / `sas.sh` |
| `RUN_TIMES` | Comma-separated runtime priority, e.g. `sas,js,py` — options: `sas`, `js`, `py`, `r`. Each needs its path: `SAS_PATH`, `NODE_PATH`, `PYTHON_PATH`, `R_PATH` |
| `SASJS_ROOT` | Working directory: SAS WORK, staged files, drive, config |
| `DRIVE_LOCATION` | Location for files, sasjs packages, `appStreamConfig.json` |
| `PROTOCOL` / `PORT` | `http` (default) or `https` (needs `PRIVATE_KEY`, `CERT_CHAIN`, optional `CA_ROOT`); default port 5000 |
| `SAS_OPTIONS` / `SASV9_OPTIONS` | Extra SAS system options auto-applied to sessions (Windows vs Unix), e.g. `-NOXCMD` |
| `DB_CONNECT` / `DB_TYPE` | MongoDB connection string / type — required for server mode |
| `AUTH_PROVIDERS` + `LDAP_*` | LDAP auth: `LDAP_URL`, `LDAP_BIND_DN`, `LDAP_BIND_PASSWORD`, `LDAP_USERS_BASE_DN`, `LDAP_GROUPS_BASE_DN` |
| `CORS` / `WHITELIST` | CORS is only applied when `CORS=enable`, and only origins in `WHITELIST` (space-separated) receive `Access-Control-Allow-Origin` — an empty whitelist means NO cross-origin calls work |
| `MOCK_SERVERTYPE` / `STATIC_MOCK_LOCATION` | Emulate `sas9`/`sasviya` API responses for frontend testing against a sasjs server (static canned files only — no logic) |
## Developing against the API
- Server type for @sasjs/adapter / CLI targets is `SASJS` (`serverType: 'SASJS'`); auth is token-based.
- The REST API is self-documented via Swagger on the running instance.
- Server-side execution uses `ms_*` macros from @sasjs/core (e.g. `ms_createfile`, `ms_adduser2group`) — services/jobs deployed by the CLI work as on other platforms.
- Repo layout (for contributors): `api/` (Express/TypeScript backend: controllers, routes, middlewares, model), `web/` (frontend), `restClient/` (REST examples), `mongo-seed/` (server-mode DB seed).
## Mock services with the JS runtime (no SAS required)
With `RUN_TIMES=js` (and `NODE_PATH` set), any `.js` file on SASjs Drive is an executable Stored Program — this is how react-seed-app / Data Controller provide **mock backends** for frontend development. Desktop mode (`MODE=desktop`) has no auth, which makes local mocking trivial.
Writing a JS stored program (docs: https://server.sasjs.io/storedprograms/#js-programs):
- The runtime template predeclares `const fs = require('fs')`, `_program`, `weboutPath`, `_SASJS_TOKENFILE`, `_SASJS_WEBOUT_HEADERS`, `_SASJS_USERNAME` / `_SASJS_USERID` / `_SASJS_DISPLAYNAME`, `_METAPERSON`, `_METAUSER`, `SASJSPROCESSMODE`. **Do NOT redeclare `fs`** — `const fs = require('fs')` in your program crashes it with `Identifier 'fs' has already been declared`.
- Output: assign a JSON **string** to `_webout` (e.g. `_webout = JSON.stringify({...})`); it is written back only if non-empty. `console.log()` output is returned in the response `log` (like a SAS log). Custom response headers can be written as lines to the `_SASJS_WEBOUT_HEADERS` file.
- Mimic real services by including the standard SASjs automatic fields in the JSON: `_PROGRAM` (from `_program`), `SYSDATE` / `SYSTIME` (format `DDMMMYY` / `HH:mm`), `_METAUSER`, `SASJSPROCESSMODE`.
- URL/body parameters arrive as `const <name> = \`<value>\`` strings.
- Input tables (the `sasjs_tables` mechanism) arrive **either** as an inline CSV const **or** — when the adapter sends multipart — as an uploaded `<name>.csv` file in the session folder, referenced by generated module-scope consts (handle BOTH):
- `_WEBIN_FILE_COUNT` (always created), `_WEBIN_NAME<n>` (table/field name), `_WEBIN_FILENAME<n>` (original filename), `_WEBIN_FILEREF<n>` (file **contents**, a Buffer from `fs.readFileSync` — call `.toString('utf8')`)
- these consts are **not on `globalThis`** — look them up with `typeof` guards or direct `eval()` in module scope (server-side JS, no CSP)
- adapter CSV quirks: header row is **space-separated** `name:format.` entries (e.g. `rootdir:$char256.`) — strip the `:format` suffix; lines end CRLF; values containing special characters are wrapped in double quotes with `""` escaping
- Adapter response shape: `sasjs.request()` resolves with the webout JSON **already unwrapped** — output tables are arrays of row objects directly on the response (`res.mytable[0].COL`). A table named `result` is perfectly fine (`res.result` is then that array); do NOT add your own `res.result`-unwrapping layer, it breaks exactly that case.
- Third-party npm packages are NOT resolvable at runtime — bundle the service first (e.g. `npx webpack --mode none --target node --entry <file> --output-path sasjsbuild/... --output-filename <name>.js`), then `sasjs build` / `sasjs deploy`.
Deploying mocks:
- `sasjs fs sync` does NOT work on a JS-only server (it generates and executes SAS code to hash remote files). Upload files directly via the Drive API instead: `DELETE` then `POST /SASjsApi/drive/file?_filePath=<appLoc>/services/<folder>/<name>.js` (multipart `file` field). In desktop mode no auth headers are needed; in server mode read the `Authorization` header line from `_SASJS_TOKENFILE`.
- Mocks can be stateful with the predeclared `fs`. Prefer real locations over `/tmp`: the SASjs Drive root is derivable from `weboutPath` (`<root>/sessions/<id>/webout.txt` → `path.resolve(weboutPath, '..', '..', '..', 'drive')`), and a mock `configure`-style service can treat a configured folder as a real local path (the server IS local). `require('path')` and other core modules work (only `fs` is predeclared).
- A JS program can even call the server's own REST API (`http://127.0.0.1:$PORT/SASjsApi/...`) — e.g. to rewrite a streamed `index.html` on the Drive (`GET` + `PATCH /SASjsApi/drive/file`).
Gotchas:
- The packaged binaries (`api-linux` etc.) reject some globally-exported `NODE_OPTIONS` (e.g. `--network-family-autoselection`) — start with `NODE_OPTIONS="" ./api-linux`.
- AppStream URLs redirect to a trailing slash (`/AppStream/MyApp` → 301 → `/AppStream/MyApp/`) — test/automation scripts should use the trailing-slash URL directly.
+11 -46
View File
@@ -3,52 +3,6 @@
# Using `--silent` helps for showing any errs in the first line of the response
# The first line is picked up by the VS Code GIT UI popup when rc is not 0
# Scan staged changes for secrets before anything else runs.
# The binary is pinned by the @nogoo9/gitleaks devDependency and lands
# in node_modules/.bin after npm i. Commits are blocked until it is
# installed so no clone silently skips the secrets scan.
gitleaks_bin=node_modules/.bin/gitleaks
if [ ! -x "$gitleaks_bin" ]; then
echo "❌ gitleaks not found - run 'npm i' to install it"
exit 1
fi
if ! "$gitleaks_bin" protect --staged --redact; then
echo "❌ gitleaks detected a potential secret in your staged changes"
echo "Remove the secret, or add a .gitleaksignore entry if it is a false positive"
exit 1
fi
# Every tracked .npmrc is a policy file - root, sas/ and client/ - so a registry
# credential pasted into any of them is one `git add -A` from being committed.
# Each staged one is checked, not just the client.
for npmrc in $(git diff --cached --name-only --diff-filter=ACMR | grep -E '(^|/)\.npmrc$'); do
if git show ":$npmrc" | grep -Eq '(_authToken|_auth[^a-z]|_password|:_auth)'; then
echo "ERROR: $npmrc holds a registry credential - keep it in ~/.npmrc instead"
exit 1
fi
done
## Avoid large commits
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
size_limit=$((2 * 1024 * 1024)) # 2MB
# Sum the sizes of the blobs this commit would introduce: the staged
# added/copied/modified/renamed/typechanged entries in the index.
# https://git-scm.com/docs/git-cat-file#Documentation/git-cat-file.txt---batch-check
# Renames are treated as add+delete (--no-renames) and pure mode changes
# (same blob, different permissions) add no new blob, so both are safe.
commit_size=$(git diff --cached --raw --no-renames --diff-filter=ACMRT |
awk '$3 != $4 { print $4 }' |
git cat-file --batch-check='%(objectsize)' |
awk '{ s += $1 } END { print s + 0 }')
if [ "$commit_size" -ge "$size_limit" ]; then
echo "Commit size is too large: $commit_size > $size_limit"
echo "Force commit using --no-verify"
exit 1
fi
if npm run --silent lint:check:silent ; then
exit 0
else
@@ -56,3 +10,14 @@ else
echo "❌ Prettier check failed! We ran lint:fix for you. Please add & commit again."
exit 1
fi
## Avoid large commits
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
size_limit=$((2 * 2**20)) # 2mbs
# https://git-scm.com/docs/git-rev-list#Documentation/git-rev-list.txt---disk-usage
commit_size=$(git rev-list --disk-usage HEAD^..HEAD)
test "$commit_size" -lt "$size_limit" || (
echo "Commit size is too large: $commit_size > $size_limit"
echo "Force commit using --no-verify"
exit 1
)
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env bash
# Fail a client job before it starts when the runner cannot hold it.
#
# Every client job here compiles the frontend from cold: `ng build
# --configuration production` peaks at 3.8 GB resident on a 4 vCPU box
# (measured with no .angular/cache present, which is what a fresh runner
# has), and the Cypress and Lighthouse jobs add a dev server and a browser on
# top of that. On a 4 GB runner the kernel OOM killer takes the build part way
# through, which surfaces as `exitcode '137'` at `ng build`, or as Karma
# disconnecting mid-suite ("no message in 30000 ms"). Both read as a flaky
# test rather than as an undersized box, and both cost a whole CI slot.
#
# 8 GB is the minimum supported runner size: it leaves roughly 2x headroom
# over that peak. This check runs before the installs, so an undersized runner
# fails in seconds with the numbers in the log.
#
# The floor is 7 GB rather than 8192 MB because an 8 GB box does not report 8
# GB: the fleet's report 7751 MB total, the rest going to firmware and the
# kernel. A 4 GB box reports under 4000 MB, so 7 GB separates the two sizes
# with room for a box that reserves a little more than ours do.
#
# What this does not catch is oversubscription: two client jobs landing on one
# box. A box that passes the floor can still OOM a build when another job is
# resident, and MemAvailable at start is the only sign of it - that is reported
# as a warning, not a failure, because a busy box is transient where a small
# box is not. Keep one client job per runner.
#
# Usage: check-runner-memory.sh [min_total_mb]
# or: MIN_TOTAL_MB=4096 check-runner-memory.sh
set -euo pipefail
MIN_TOTAL_MB="${1:-${MIN_TOTAL_MB:-7000}}"
# A typo here (8GB, a stray flag, an empty variable) would otherwise reach the
# integer test under `set -e` and abort with "integer expression expected".
case "$MIN_TOTAL_MB" in
''|*[!0-9]*)
echo "usage: check-runner-memory.sh [min_total_mb] - got '$MIN_TOTAL_MB'" >&2
exit 2
;;
esac
meminfo_field() {
awk -v key="$1:" '$1 == key { print $2 }' /proc/meminfo
}
total_kb=$(meminfo_field MemTotal || true)
avail_kb=$(meminfo_field MemAvailable || true)
cpus=$(nproc 2>/dev/null || echo '?')
if [ -z "$total_kb" ]; then
echo "runner memory: /proc/meminfo has no MemTotal - cannot measure, continuing"
exit 0
fi
total_mb=$(( total_kb / 1024 ))
avail_mb=$(( ${avail_kb:-0} / 1024 ))
# Older kernels have no MemAvailable; report that rather than 0, which would
# read as a permanently exhausted runner.
if [ -n "$avail_kb" ]; then
avail_display="${avail_mb} MB available"
else
avail_display="available unknown"
fi
# A container usually sees the host's RAM, but may carry its own lower ceiling
# (cgroup v2 memory.max, v1 memory.limit_in_bytes). That ceiling is what the
# build is actually killed against, so it counts as the runner's size.
limit_mb=""
for f in /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory/memory.limit_in_bytes; do
[ -r "$f" ] || continue
v=$(cat "$f")
case "$v" in
''|max|*[!0-9]*) continue ;;
esac
# An unlimited v1 cgroup reports a sentinel near 2^63 bytes.
[ "$v" -lt 100000000000 ] || continue
candidate=$(( v / 1024 / 1024 ))
if [ -z "$limit_mb" ] || [ "$candidate" -lt "$limit_mb" ]; then
limit_mb="$candidate"
fi
done
echo "runner memory: ${total_mb} MB total, ${avail_display}, ${cpus} vCPU${limit_mb:+, ${limit_mb} MB cgroup limit}"
effective_mb=$total_mb
if [ -n "$limit_mb" ] && [ "$limit_mb" -lt "$effective_mb" ]; then
effective_mb=$limit_mb
fi
if [ "$effective_mb" -lt "$MIN_TOTAL_MB" ]; then
echo "::error::runner has ${effective_mb} MB of memory, a client job needs at least ${MIN_TOTAL_MB} MB (8 GB is the supported minimum; a cold production build alone peaks at about 3.8 GB, and the Cypress and Lighthouse jobs add a dev server and a browser to that)."
exit 1
fi
# Not a gate - a second job landing on the same box shows up here first.
if [ -n "$avail_kb" ] && [ "$avail_mb" -lt 5000 ]; then
echo "::warning::only ${avail_mb} MB is available on this runner; another job may be running on the same box"
fi
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
# Decide whether this pull request can change the Cypress job's outcome.
#
# The Cypress job runs the mocked estate (sas/mocks) against the client, so a
# pull request that touches none of what it exercises cannot change its
# result: a sas/ service change is not deployed there, and a documentation
# change is not read. The build job decides once and publishes the answer as a
# job output, so its gate on the cold production build and the artifact upload
# cannot disagree with the job that downloads them - re-deciding in the second
# job could see a moved base branch and ask for an artifact the first job never
# built. The Lighthouse job takes the same decision: it reads the client build,
# the mock services and the CLI pin, and nothing else.
#
# Writes client=true|false to $GITHUB_OUTPUT when a decision can be made, and
# prints the files it saw. It exits 0 WITHOUT writing anything when it cannot
# decide (no base ref, a failed fetch, a failed diff): every caller gates on
# `!= 'false'`, so an undecidable pull request runs everything. A CI change
# whose worst case is "slow" is safe; one whose worst case is "silently tests
# nothing" is not, and one whose worst case is "every pull request goes red
# because a fetch failed" is not acceptable either.
#
# .gitea/scripts/ is in the set because the spec list (cypress-specs.sh) and
# the memory gate (check-runner-memory.sh) live there: a pull request that
# changes what the suite runs, or the gate that lets it run, has to run it.
# build.yaml is in the set for the same reason.
#
# sas/package.json is in the set because the job installs the CLI from the
# version it pins (`CLI_VERSION=$(node -p "...dependencies['@sasjs/cli']")`
# in "Deploy mocked services"): bumping that pin changes the toolchain the
# suite runs, even though no file under sas/mocks/ changes. The sas lockfile
# is not in the set - the job installs that CLI globally by version string,
# so the lockfile cannot change what it resolves.
#
# sas/mocks/test/ is dropped from the list before the match. Those files are
# node:test harnesses that read the payload files; the job deploys
# sas/mocks/sasjs/ and nothing else, so a pull request that changes only the
# tests cannot change what the browser is served. Any other file under
# sas/mocks/ still counts, including a change that touches the tests alongside
# a service.
#
# Usage: BASE_REF=<base branch> bash .gitea/scripts/cypress-changes.sh
set -uo pipefail
# The paths the Cypress job reads, and nothing else. The tests in
# tests/cypress-changes.test.sh exist because this list is the whole gate: a
# dropped alternative would silently stop the suite running on a class of
# change, and the only symptom would be a green job that finished sooner.
PATHS='client/|sas/mocks/|sas/package\.json|\.gitea/scripts/|package-lock\.json|\.gitea/workflows/build\.yaml'
# --match < file-list reads newline-separated paths on stdin and prints
# true/false, so the matcher can be exercised with fixed lists and no
# repository. It is what the test above drives.
# A path list on stdin, minus the mock tree's own tests, which the job does
# not deploy.
served_changes() { grep -v '^sas/mocks/test/' || true; }
if [ "${1:-}" = "--match" ]; then
if served_changes | grep -qE "^(${PATHS})"; then echo true; else echo false; fi
exit 0
fi
BASE="${BASE_REF:-}"
if [ -z "$BASE" ]; then
echo "no base ref (not a pull request) - running the suite"
exit 0
fi
if ! git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE}:refs/remotes/origin/${BASE}"; then
echo "::warning::could not fetch ${BASE} - running the suite rather than deciding"
exit 0
fi
if ! CHANGED="$(git diff --name-only "refs/remotes/origin/${BASE}" HEAD)"; then
echo "::warning::could not diff against ${BASE} - running the suite rather than deciding"
exit 0
fi
if served_changes <<<"$CHANGED" | grep -qE "^(${PATHS})"; then
echo "client=true" >>"${GITHUB_OUTPUT:-/dev/null}"
echo "the Cypress job's inputs changed:"
else
echo "client=false" >>"${GITHUB_OUTPUT:-/dev/null}"
echo "::notice::Cypress suite skipped - nothing under client/, sas/mocks/ (outside sas/mocks/test/) or .gitea/scripts/ changed"
echo "Skipping the Cypress suite - nothing it exercises changed:"
fi
printf '%s\n' "$CHANGED"
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# The Cypress specs CI runs, and the order they run in.
#
# ORDER MATTERS. These specs share one mock estate that is seeded once per job,
# so several of them depend on the state they find:
#
# - csv-limited asserts the free-tier "Locked Feature" modal, which an earlier
# spec can remove by planting a valid licence. It has always run first.
# - hook-programs asserts that an edit raises an approval row against its
# target table; it runs early, before other specs have added rows to the
# approval tables.
# - metanav only reads the metadata tree, and applies a licence key so it can
# run on its own; it goes last so that key cannot change another spec.
# - va-ddc-host asserts nothing about licensing, so it works on either side of
# metanav; it sits before it, which leaves metanav last.
#
# Specs added later are appended rather than inserted, so the sequence that was
# known green keeps its shape. metanav is the exception - it has to stay last for
# the key it plants, so a new spec goes in front of it.
#
# download was held out while its selectors were stale - see
# https://git.datacontroller.io/dc/dc/issues/398. Its first case uploads a fixture
# to mpe_x_test and submits it, so it creates the approval it downloads instead of
# depending on what earlier specs leave behind. That approval stays: the CI job
# deletes the appLoc and re-runs makedata before the specs, so nothing accumulates
# between runs, but a hand-run against a reused estate leaves one pending row per
# run. It is appended because a spec added later goes on the end rather than
# reordering the sequence that was known green.
set -euo pipefail
SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va deploy-folder-picker download va-ddc-host metanav"
# Specs that exist but are held out of CI, each with the reason. Listed here so
# the coverage check below can tell a deliberate exclusion from a forgotten file.
# Nothing is held out at present.
EXCLUDED=""
case "${1:-}" in
all)
# comma separated, the form `cypress run --spec` takes
for name in $SPECS; do printf 'cypress/e2e/%s.cy.ts,' "$name"; done | sed 's/,$//'
;;
coverage)
on_disk=$(cd "$2/client" && ls cypress/e2e/*.cy.ts | xargs -n1 basename | sed 's/\.cy\.ts$//' | sort)
listed=$(printf '%s\n' $SPECS $EXCLUDED | sort)
missing=$(comm -23 <(echo "$on_disk") <(echo "$listed"))
if [ -n "$missing" ]; then
echo "Specs that are neither run nor explicitly excluded:"
echo "$missing"
echo "Add them to SPECS (or EXCLUDED, with a reason) in .gitea/scripts/cypress-specs.sh"
exit 1
fi
echo "All $(echo "$on_disk" | wc -l) specs are accounted for: $(echo $SPECS | wc -w) run, $(echo $EXCLUDED | wc -w) excluded."
;;
*)
echo "usage: $0 {all|coverage <repo-root>}" >&2
exit 2
;;
esac
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env node
// Publish the Lighthouse scores as ONE pull-request comment, updated in place.
//
// Why a script rather than a step of shell: the reports are JSON, the comment
// has to be found again on the next run and patched instead of posted (a PR
// that gets a fresh comment every push is noise), and neither jq nor a
// token-safe way to build the body is guaranteed in the job image. Node is,
// because the audit already needs it for lhci.
//
// Contract with the workflow:
// GITEA_TOKEN / GITHUB_TOKEN token to comment with (either name)
// GITHUB_API_URL e.g. https://git.datacontroller.io/api/v1
// GITHUB_REPOSITORY owner/repo
// PR_NUMBER the pull request to comment on
// RUN_URL link to the run, so the table has provenance
// REPORTS_DIR default client/lighthouse-reports
//
// It never fails the job: the audit's verdict is the audit step's business, and
// a comment that could not be posted must not turn a green audit red. It exits
// non-zero ONLY when it cannot find the reports at all, which means the
// workflow is wired to the wrong directory and someone should know.
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const MARKER = '<!-- lighthouse-report -->'
const dir = process.env.REPORTS_DIR || 'client/lighthouse-reports'
const api = (process.env.GITHUB_API_URL || '').replace(/\/$/, '')
const repo = process.env.GITHUB_REPOSITORY || ''
const pr = process.env.PR_NUMBER || ''
const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN || ''
const runUrl = process.env.RUN_URL || ''
const label = (url) => {
const short = String(url)
.replace(/^https?:\/\/[^/]+\/AppStream\/clickme\//, '')
.replace(/^https?:\/\/[^/]+\//, '/')
// A navigation that ended on the app root strips to nothing; the audit did
// visit something, so say what rather than printing an empty cell.
return short === '' ? '/' : short
}
const fmt = (score) => (typeof score === 'number' ? score.toFixed(2) : '-')
function loadReports() {
if (!existsSync(dir)) return []
const files = readdirSync(dir).filter((f) => f.endsWith('.json'))
// manifest.json is the filesystem target's index: url -> jsonPath. Prefer it,
// because a report's own finalUrl may have lost the fragment the route is
// identified by.
const manifestPath = join(dir, 'manifest.json')
const out = []
const manifestFiles = []
if (existsSync(manifestPath)) {
try {
const entries = JSON.parse(readFileSync(manifestPath, 'utf8'))
for (const e of entries) {
const p = e.jsonPath || e.jsonPathAbsolute
if (!p) continue
const base = String(p).split('/').pop()
const abs = join(dir, base)
if (!existsSync(abs)) continue
manifestFiles.push(base)
out.push({ url: e.url, report: JSON.parse(readFileSync(abs, 'utf8')) })
}
} catch (e) {
console.error(`lighthouse-report: could not read manifest.json (${e.message}); falling back to the json files`)
}
}
// Second pass for anything the manifest did not account for. Deduplicated by
// FILE, not by finalUrl: the entry the manifest lists with a fragment can be
// the same report whose own finalUrl lost it, and matching on the URL then
// printed that report twice - once under its route and once under '/'.
const used = new Set(manifestFiles)
for (const f of files) {
if (f === 'manifest.json' || used.has(f)) continue
let report
try {
report = JSON.parse(readFileSync(join(dir, f), 'utf8'))
} catch {
continue
}
if (!report || !report.categories || !report.finalUrl) continue
out.push({ url: report.finalUrl, report })
}
return out
}
function render(reports) {
const rows = reports
.map((r) => {
const c = r.report.categories || {}
return `| \`${label(r.url)}\` | ${fmt(c.performance?.score)} | ${fmt(c.accessibility?.score)} | ${fmt(c['best-practices']?.score)} | ${fmt(c.seo?.score)} |`
})
.join('\n')
const a11y = reports.map((r) => r.report.categories?.accessibility?.score).filter((s) => typeof s === 'number')
const worst = a11y.length ? Math.min(...a11y) : null
return [
MARKER,
'### Lighthouse',
'',
'| Route | Performance | Accessibility | Best practices | SEO |',
'|---|---|---|---|---|',
rows,
'',
worst === null
? 'No accessibility score in the reports.'
: `Lowest accessibility score: **${worst.toFixed(2)}** (the gate is a median of 0.85 while issue 497 is open).`,
runUrl ? `\nRun: ${runUrl}` : '',
].join('\n')
}
async function call(method, path, body) {
const res = await fetch(api + path, {
method,
headers: {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${(await res.text()).slice(0, 200)}`)
return res.status === 204 ? null : res.json()
}
/**
* The report comment this script owns, or null.
*
* The comment list is paged: a pull request with a long review history runs past
* the first page, and looking only there turned "updated in place" into a fresh
* comment on every push - the noise this script exists to prevent. The page
* limit is a bound on the walk, not an expectation.
*/
async function findComment(pr, marker) {
const perPage = 50
const maxPages = 10
for (let page = 1; page <= maxPages; page++) {
const comments = await call(
'GET',
`/repos/${repo}/issues/${pr}/comments?limit=${perPage}&page=${page}`
)
const mine = (comments || []).find(
(c) => typeof c.body === 'string' && c.body.includes(marker)
)
if (mine) return mine
if (!comments || comments.length < perPage) return null
}
return null
}
async function main() {
const reports = loadReports()
if (!reports.length) {
console.error(`lighthouse-report: no reports under ${dir}`)
process.exit(1)
}
const body = render(reports)
console.log(body)
if (!api || !repo || !pr || !token) {
console.error('lighthouse-report: missing GITHUB_API_URL/GITHUB_REPOSITORY/PR_NUMBER/token - not posting')
return
}
const mine = await findComment(pr, MARKER)
if (mine) {
await call('PATCH', `/repos/${repo}/issues/comments/${mine.id}`, { body })
console.log(`lighthouse-report: updated comment ${mine.id}`)
} else {
const created = await call('POST', `/repos/${repo}/issues/${pr}/comments`, { body })
console.log(`lighthouse-report: created comment ${created?.id}`)
}
}
main().catch((e) => {
// A comment is not a gate: log it, leave the job's own verdict alone.
console.error(`lighthouse-report: ${e.message}`)
})
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# The path list in cypress-changes.sh is the whole gate: drop an alternative
# and the Cypress suite silently stops running for a class of change, with a
# green job that just finished sooner. These cases pin the list to what the
# suite actually reads, and run in the build job on every pull request.
#
# Run by hand: bash .gitea/scripts/tests/cypress-changes.test.sh
set -uo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
matcher="$here/../cypress-changes.sh"
fails=0
check() { # <expected> <path>...
local want="$1" got
shift
got=$(printf '%s\n' "$@" | bash "$matcher" --match)
if [ "$got" != "$want" ]; then
echo "FAIL: expected $want for: $*"
echo " got: $got"
fails=$((fails + 1))
fi
}
# Everything the Cypress job reads: the client it drives, the mock estate it
# deploys, the CLI pin it installs, the scripts that decide what it runs, and
# the dependency/workflow files that change the first two.
check true client/src/app/app.component.ts
check true sas/mocks/sasjs/services/admin/makedata.js
check true sas/mocks/sasjs/services/admin/createdemodata.js
check true sas/package.json
check true .gitea/scripts/cypress-specs.sh
check true package-lock.json
check true .gitea/workflows/build.yaml
# Not inputs: a sas/ service the mock estate does not deploy, documentation,
# another workflow (only build.yaml decides what these jobs run), an unrelated
# directory whose name starts with "client", and the sas lockfile (the job
# installs the CLI by version string, so the lockfile cannot change what it
# resolves).
check false sas/macros/mp_abort.sas
check false AGENTS.md
check false docs/readme.md
check false .gitea/workflows/release.yaml
check false clientx/foo.ts
check false sas/package-lock.json
# The mock tree's own tests are not deployed, so a pull request that changes
# only them cannot change what the browser is served. A change that touches
# them next to a service still counts.
check false sas/mocks/test/sas9-separators.test.js
check false sas/mocks/test/sasjs-webout.test.js
check true sas/mocks/test/sas9-separators.test.js sas/mocks/sasjs/services/x.js
check true sas/mocks/sas9/sasjs/services/editors/getdata.js
if [ "$fails" -ne 0 ]; then
echo "cypress-changes matcher: $fails case(s) failed"
exit 1
fi
echo "cypress-changes matcher: all cases passed"
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# Emits the "Verifying the download" section appended to every release body
# by the "Upload assets to release" step of release.yaml.
# Keep this text in sync with the docs page:
# docs.datacontroller.io/docs/downloads.md
set -euo pipefail
cat <<'EOF'
## Verifying the download
Every asset on this release is covered by the `SHA256SUMS` file. To verify a download, fetch `SHA256SUMS` from the release assets alongside the files you downloaded, then run:
```
sha256sum --check SHA256SUMS --ignore-missing
```
The command reports `OK` for each asset that matches. `--ignore-missing` lets you check just the files you downloaded rather than all of them.
What this protects: a matching checksum confirms the asset is byte-for-byte the file this pipeline uploaded, guarding against corrupted or tampered downloads (mirrors, proxies, interrupted transfers). It is not a signature: the hashes travel in the same release as the files, so a compromise of the forge itself could alter both. Treat it as an integrity check, not a trust anchor.
EOF
+31 -679
View File
@@ -1,148 +1,20 @@
name: Build
run-name: Lint, unit tests, Cypress suite and Lighthouse on Pull Request
run-name: Running Lint Check and Licence checker on Pull Request
on: [pull_request]
# One run per pull request: a newer push supersedes the one in flight for the
# older head. Both runs test the same thing over the same twenty-six minute
# suite, the older head can no longer be what gets merged, and this workflow
# shares three runner boxes with every other pull request in the repo - so
# leaving the old run queued only delays the head that matters. The group is the
# base build only: the release workflow is never cancelled by something it does
# not supersede.
concurrency:
group: build-pr-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
# the server the mocked services and app tests run against - bump deliberately.
#
# 4.14.0 is the pin because it is the first release that compresses its
# responses and sends cache headers for the app it streams (sasjs/server#575).
# That is not cosmetic here: the audit runs against this server, and the app it
# serves is a single 4.83MB build output. Measured on the previous pin, same
# build and same machine: FCP 8.48s and 0.34 performance with the assets served
# raw, 2.81s and 0.49 with the same bytes gzipped and hashed files immutable.
#
# 4.0.0's breaking change is the auth statuses - `POST /SASjsApi/auth/token` and
# `/auth/refresh` answer 401 where they answered 403, the undocumented DELETE on
# the logout route is gone, and `GET /SASLogon/logout` answers 500 where it
# answered 403 when the session store fails. The estate does touch all three,
# through @sasjs/adapter: auth/AuthManager.js logs out with a GET on the logout
# route and auth/getAccessTokenForSasjs.js posts the token. What it keys on is
# the call succeeding rather than the code being 403 in particular, so 403 to
# 401 changes nothing here - and the suite on this pin checks the logout path
# still completes.
#
# The floors this pin also carries, kept because a future bump has to keep
# clearing them:
#
# 3.12.0 is the floor: /ddc-host carries `timing=early` from there, which
# client/cypress/e2e/va-ddc-host.cy.ts needs. 3.12.2 also carries the
# packaged-executable fix for the same page, so the version the tests run
# against is the one that ships.
#
# 3.14.0 ships @sasjs/lint 5.0.0 (the studio reports OS commands). It declared
# @sasjs/utils ^4.7.0, which is why the envelope *formats* (SYSCC as text,
# DATE7, TIME5, E8601DT26.6) used to depend on what a fresh install resolved:
# 4.8 introduced them, a 4.7 tree did not have them. 4.14.0 declares ^4.8.0,
# so the pin guarantees them now rather than being a floor that hoped for them.
#
# 3.13.0 is the one that matters for licensing: @sasjs/utils 4.7 reports
# SYSSITE, which %webout takes from &syssite. Before it the prelude wrote an
# empty string, so the client - which routes expired and wrong-organisation
# licence keys on that value - never reached those pages on the mock estate.
#
# 3.12.3 is on top of that for a different reason: it is the first release whose
# package.json declares @sasjs/utils ^4.6.0, which is what gives webout() its
# options argument. getdata, viewdata and getcolvals pass { showmeta }, and a
# 4.5 install drops it without a word - so the pin is what makes the metadata
# members guaranteed here rather than whatever a fresh install happens to
# resolve. sas/mocks/test/harness-webout.test.js is what holds that: showmeta
# is written beside the rows, before CLOSE.
SASJS_SERVER_VERSION: '4.14.0'
NODE_VERSION: '24.15.0'
jobs:
Build-and-ng-test:
runs-on: ubuntu-latest
outputs:
# The Cypress job consumes this instead of deciding again: the base
# branch can move between the two jobs, and a second answer could say
# "run" after this job had already skipped the build and the upload,
# which would fail there as a missing artifact. Empty (the decision
# could not be made) means the Cypress job runs everything.
client: ${{ steps.changes.outputs.client }}
steps:
- uses: actions/checkout@v4
- name: Check runner size
# An undersized runner dies inside `ng build` (exitcode 137) or as a
# Karma disconnect twenty minutes in. Fail here instead, in seconds,
# with the measured numbers in the log - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- name: Detect the Cypress job's inputs
id: changes
# Decides whether the Cypress job will run at all, so the production
# build and its artifact upload can be skipped when nothing the suite
# exercises has changed. Same script the Cypress job itself calls, so
# the two jobs cannot disagree about which paths matter.
env:
BASE_REF: ${{ github.base_ref }}
run: bash .gitea/scripts/cypress-changes.sh
- name: Test the changed-path matcher
# The path list is the whole gate: a dropped alternative silently stops
# the suite running for a class of change, and the only symptom is a
# green job that finished sooner. The cases pin the list to what the
# suite reads.
run: bash .gitea/scripts/tests/cypress-changes.test.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Scan for secrets (gitleaks)
# The local pre-commit hook is suppressed entirely when .npmrc sets
# ignore-scripts=true (the prepare script that would activate the
# hooks never runs), so CI is the enforcement point: a leaked
# credential must fail the build, not just a commit.
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
# gitleaks-action requires a license key for organization repos.
# Runs before the registry config is written, so the scan cannot see
# the token this job injects.
# --no-git scans the working tree, which is what a pull request gate
# is about.
#
# The cache steps below deliberately run AFTER this one: the Angular
# cache holds webpack/babel JSON full of `{ token: ... }` fragments,
# and a restored copy in the tree is scanned as a pile of generic API
# keys (it failed the gate once, on run 3174).
run: |
npm ci
node_modules/.bin/gitleaks detect --no-git --source . --redact --no-banner -v
- name: Restore the npm download cache
# The runner's embedded cache server is reachable from job containers
# (they share a network with the runner), so these entries can actually
# come back. The key is the client lockfile: a dependency change
# invalidates it, and the prefix restore-keys keep the previous entries
# usable while the new ones are fetched.
#
# act_runner scopes cache entries to the REPOSITORY, not the ref (the
# handler stamps each entry with its repo and checks it on read), so
# the base branch is part of the key: a pull request into release/7.22
# cannot seed the entry a pull request into main restores. Entries
# still live on the runner that saved them - each act_runner runs its
# own cache server - so a box warms its cache from its own first run.
#
# An unreachable cache server degrades both the restore and the save to
# a warning - the action never fails the job - so a box on an older
# runner config costs a timeout, not a red build.
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-client-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
npm-client-${{ github.base_ref }}-
- name: Install Google Chrome
run: |
apt-get update
@@ -150,172 +22,48 @@ jobs:
apt install -y ./google-chrome*.deb
- name: Write .npmrc file
run: |
# the registry secret stays outside the repo: client/.npmrc is
# tracked now, and one git add -A would commit a live token
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
run: echo "$NPMRC" >> client/.npmrc
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | \
echo "${{ secrets.SHEET_PWD }}" | \
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc); the client's own
# generator runs explicitly, because the app imports the file it makes
npm ci --ignore-scripts
npm run postinstall
# cypress's binary is installed in the Cypress job, where it is
# actually used; nothing in this job starts it, so downloading and
# unzipping it here only cost time (11s, measured).
- name: Restore the Angular build cache
# `ng build` and both `ng test` runs use this directory, so a restored
# entry speeds up the production build and the Karma suites alike.
#
# This step MUST stay below "Install dependencies". postinstall runs
# scripts/strip-clr-base64-fonts.mjs, which edits Clarity's CSS inside
# node_modules and then deletes client/.angular/cache, because the
# builder treats node_modules as immutable and would otherwise serve
# styles built from the unstripped file (measured: a marker @font-face
# injected into clr-ui.min.css is absent from a warm build's CSS and
# present in a cold one). Every run strips before it builds, so every
# entry in this cache was produced from the stripped file - restoring
# after the clear is what keeps that true.
#
# Exact key only, no restore-keys: the builder cannot see a change
# inside node_modules, so an entry compiled against a different
# Clarity must not be restored. A lockfile bump misses and rebuilds,
# which is the correct outcome.
#
# Angular keeps the cache in memory when CI is set, so client/
# angular.json sets cli.cache.environment to "all" - without it this
# step has nothing to save ("Path Validation Error: Path(s) specified
# in the action for caching do(es) not exist").
#
# Saving is skipped for a key that already exists, so this refreshes
# when client/package-lock.json changes; bump the v1- prefix to force
# a reset by hand. The base ref keeps a pull request into main from
# seeding what a pull request into release/7.22 restores.
uses: actions/cache@v4
with:
path: client/.angular/cache
key: angular-v1-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
npm ci
- name: Check audit
# Root and sas fail on moderate and above (npm's default), client on
# low and above - the flags below are what makes that true, so they have
# to stay in step with this comment.
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
run: |
npm audit --omit=dev
cd ./sas
npm audit --omit=dev
cd ../client
npm audit --omit=dev --audit-level=low
- name: Check archiver directory walk
# the readdir-glob minimatch override in client/package.json is a major
# bump; archiver's directory() is the only runtime caller of that path,
# so exercise it rather than trusting that the install succeeded
run: |
cd client
npm run check:archiver
npm audit --omit=dev
- name: Lint check
run: npm run lint:check
- name: SAS lint check
# sas/.sasjslint promotes each rule to "error" as its findings are
# cleared, and only errors change the exit code - so this gate fails on
# a regression in a cleared rule and passes while other rules still
# carry warnings.
run: |
cd sas
npm ci --ignore-scripts
npm run lint:sas
- name: Licence checker
run: |
cd client
npm run license-checker
- name: Mock estate service tests
# The deploy screen's questions (ACTION=DIRLIST / WRITECHECK) are
# answered by the mock makedata service on the browser-hosted sites.
# These run that service against a temporary estate, so a browse that
# seeds the site and deletes the program fails here instead of on the
# staging site, where it only shows as a wizard that cannot be used.
run: node --test sas/mocks/test/*.test.js
- name: Angular Tests
run: |
cd client
npm run test:headless
- name: Angular Tests in other timezones
# The VA filter clauses are built from local calendar dates, so a
# UTC-only run cannot see a date that slips a day west of UTC or east of
# it. Only the VA specs carry that property, so both scripts run that
# subset rather than the whole suite a second and third time.
run: |
cd client
npm run test:tz-west
npm run test:tz-east
- name: Every Cypress spec is in the CI list
# The spec list used to be spelled out inline in --spec, and five files
# quietly fell out of it - among them the VA embed suite. Fails when a
# spec on disk is not listed.
run: bash .gitea/scripts/cypress-specs.sh coverage .
- name: Production Build
# The build exists to give the Cypress job the client/dist it streams
# as the mock estate's frontend. When nothing that job exercises has
# changed, that job skips and nobody downloads the artifact, so the
# cold build (109s, measured on run 3132) would be spent on no one.
if: steps.changes.outputs.client != 'false'
run: |
cd client
npm run build
- name: Save the production build for the Cypress job
# Gated with the build it packages: when the Cypress job skips, this
# artifact has no consumer.
if: steps.changes.outputs.client != 'false'
# The Cypress job needs client/dist: the server-ci target streams it as
# the 'clickme' web app (see CONTRIBUTING.md). Handing it the build this
# job has already made saves a second cold `npm run build` (1.6 minutes,
# measured over thirteen runs) and guarantees both jobs test the same
# bytes.
#
# if-no-files-found: error, because an empty artifact is worse than a
# failed upload: the Cypress job would download nothing, deploy a service
# pack with no frontend, and the symptom would reappear there as the
# "webSourcePath ... doesn't exist" line this artefact exists to remove.
#
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all - the download in
# Build-and-test-development is @v3 for the same reason.
uses: actions/upload-artifact@v3
with:
name: client-dist
path: client/dist
if-no-files-found: error
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
Build-and-test-development:
runs-on: ubuntu-latest
needs: Build-and-ng-test
@@ -327,57 +75,16 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Take the changed-path decision from the build job
id: changes
# The build job made this decision before it built (or skipped) the
# client-dist this job downloads, and published it as a job output.
# Deciding again here could disagree if the base branch moved in
# between, and the disagreement would surface as a missing artifact.
# An empty value means that job could not decide, so the suite runs.
env:
CLIENT: ${{ needs['Build-and-ng-test'].outputs.client }}
run: |
case "${CLIENT:-}" in
true) echo "client=true" >> "$GITHUB_OUTPUT" ;;
false) echo "client=false" >> "$GITHUB_OUTPUT" ;;
*) echo "the build job published no decision - running the suite" ;;
esac
- name: Cypress suite not run - no client changes
# A named step rather than a run of greyed-out ones, so the job summary
# says why it took a minute: this job reports success having tested
# nothing, which is the intended behaviour for a pull request that
# changes no client code. If this job is a required check, "green" for
# such a pull request means "not applicable", and this step is the
# evidence for it.
if: steps.changes.outputs.client == 'false'
run: |
echo "No client, sas/mocks, sas/package.json or .gitea/scripts change in this pull request - the Cypress suite was skipped by design."
echo "The build job's 'Detect the Cypress job's inputs' step lists the files it saw, and warns instead if it could not decide (in which case the suite runs)."
- name: Check runner size
if: steps.changes.outputs.client != 'false'
# Each Cypress job compiles the frontend and runs a browser on top; a
# runner too small for that fails as a flaky-looking disconnect - see
# the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
if: steps.changes.outputs.client != 'false'
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
if: steps.changes.outputs.client != 'false'
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install system dependencies
if: steps.changes.outputs.client != 'false'
run: |
apt-get update
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
@@ -388,423 +95,68 @@ jobs:
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
- name: Write cypress credentials
if: steps.changes.outputs.client != 'false'
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
shell: bash
env:
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
- name: Restore the npm download cache
if: steps.changes.outputs.client != 'false'
# The build job saves this key with the same lockfile, so npm ci here
# should not download a tarball that job already fetched.
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-client-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
npm-client-${{ github.base_ref }}-
- name: Restore the Cypress binary
if: steps.changes.outputs.client != 'false'
# npx cypress install fetches a ~50MB binary per run without this; the
# key follows the lockfile, and a stale hit is re-checked by the installer.
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-binary-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
cypress-binary-
- name: Install dependencies
if: steps.changes.outputs.client != 'false'
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc); the client's own
# generator runs explicitly, because the app imports the file it makes
npm ci --ignore-scripts
npm run postinstall
# cypress downloads its binary in a dependency install hook, which the
# policy skips, so it is installed explicitly here
npx cypress install
- name: Fetch the production build
if: steps.changes.outputs.client != 'false'
# The server-ci target streams client/dist as the 'clickme' web app (see
# CONTRIBUTING.md), so the production build has to exist before the mock
# services are deployed. Without it the deploy logs
# "webSourcePath: .../client/dist ... doesn't exist" and streams no
# frontend. It is the artefact Build-and-ng-test published, so this job
# does not compile the frontend a second time.
uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Cache the npm downloads
if: steps.changes.outputs.client != 'false'
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
- name: Setup and start SASjs server
if: steps.changes.outputs.client != 'false'
run: |
npm i -g pm2@7.1.2
npm i -g pm2
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
echo HTTP_PORT=5000 >> .env
# /ddc-host is a testing tool, so the server answers 404 for it unless
# this is set. va-ddc-host.cy.ts drives the app through that page with
# timing=early.
echo DDC_HOST_ENABLED=true >> .env
# The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
exit 0
fi
sleep 2
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
exit 1
pm2 start api-linux --wait-ready
- name: Deploy mocked services
if: steps.changes.outputs.client != 'false'
run: |
cd ./sas/mocks/sasjs
# The CLI comes from the version sas/package.json pins, as docs.yaml and
# release-assets.yaml already do: an unpinned global install runs
# whatever release is current, unreviewed.
CLI_VERSION=$(node -p "require('../../package.json').dependencies['@sasjs/cli']")
npm install -g "@sasjs/cli@${CLI_VERSION}"
npm install -g replace-in-files-cli@4.0.0
# Remove any previous deployment (drive folder delete API) so the
# deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
npm install -g @sasjs/cli
npm install -g replace-in-files-cli
sasjs cbd -t server-ci
# Seed the DC database (mock data files on the drive). The payload
# asks for OUTFMT=JSON, so the CLI parses the response and a failed
# seed fails the job - createdemodata runs next and would only report
# a missing control library.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
# Then the Cypress fixtures: TESTDATA, the search/wide viewers and the
# multi-load library. makedata builds only what a real deployment
# builds, so the specs need this second service.
sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
- name: Prepare and run frontend and cypress
if: steps.changes.outputs.client != 'false'
# One job, one SASjs server, one ng serve, one Chrome: four of each on one
# runner starved the app under test and every failure in that run was a
# timeout, not an assertion.
timeout-minutes: 50
env:
CYPRESS_USERNAME: ${{ secrets.CYPRESS_USERNAME_SASJS }}
CYPRESS_PWD: ${{ secrets.CYPRESS_PWD_SASJS }}
timeout-minutes: 35
run: |
cd ./client
# The spec list lives in one place, and an empty list is a broken
# workflow rather than a green run with no tests.
SPECS="$(bash ../.gitea/scripts/cypress-specs.sh all)"
if [ -z "$SPECS" ]; then
echo "the spec list resolved to nothing"
exit 1
fi
echo "specs: $SPECS"
mv ./cypress.env.example.json ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'"$CYPRESS_USERNAME"'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'"$CYPRESS_PWD"'" ' ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'${{ secrets.CYPRESS_USERNAME_SASJS }}'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
cat ./cypress.env.json
npm run postinstall
# Prepare index.html to SASJS local
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
replace-in-files --regex='serverType=".*?"' --replacement='serverType="SASJS"' ./src/index.html
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
# timeout 2700: SIGTERM after 45 min so Cypress can flush screenshots
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
#
# video=false, screenshotOnRunFailure=true: recording twenty-four
# specs costs encode time on every run, and the recording is only ever
# opened after a failure. A screenshot of the failing step carries the
# same information for a fraction of the cost - and cypress.config.ts
# sets screenshotOnRunFailure false by default, so both are passed
# here, as CLI overrides, leaving local runs (and the demo recorder,
# which sets RECORD_WINDOW_SIZE) on video.
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 2700 npx cypress run --browser chrome --config video=false,screenshotOnRunFailure=true --spec "$SPECS"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
- name: Zip Cypress failure evidence
# Only on a failure: a green run has nothing to look at.
if: failure() && steps.changes.outputs.client != 'false'
- name: Zip Cypress videos
if: always()
run: |
mkdir -p ./client/cypress/screenshots ./client/cypress/videos
zip -r cypress-artifacts.zip ./client/cypress/screenshots ./client/cypress/videos
mkdir -p ./client/cypress/videos
zip -r cypress-videos ./client/cypress/videos
- name: Add cypress failure evidence as an artifact
if: failure() && steps.changes.outputs.client != 'false'
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all.
- name: Add cypress videos artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-artifacts.zip
path: cypress-artifacts.zip
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
# The Lighthouse audit, which used to be its own workflow. It lives here so it
# can reuse the production build the first job already made: an artifact
# belongs to the run that produced it and `needs:` cannot cross workflows, so
# the separate workflow had to compile the frontend a second time (92-104s
# measured) and start an ng serve it never audited - every URL in
# client/lighthouserc.js is on :5000, which the SASjs server feeds from
# client/dist.
#
# It takes the changed-path decision from the build job instead of deciding
# again, for the reason given there: the same inputs gate both, and two
# answers could disagree about whether the artifact exists.
lighthouse:
runs-on: ubuntu-latest
needs: Build-and-ng-test
# The job comments the scores back onto the pull request, so it needs to
# write there; nothing else is elevated.
permissions:
contents: read
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
- name: Take the changed-path decision from the build job
id: changes
env:
CLIENT: ${{ needs['Build-and-ng-test'].outputs.client }}
run: |
case "${CLIENT:-}" in
true) echo "client=true" >> "$GITHUB_OUTPUT" ;;
false) echo "client=false" >> "$GITHUB_OUTPUT" ;;
*) echo "the build job published no decision - running the audit" ;;
esac
- name: Lighthouse audit not run - no client changes
# A named step rather than a run of greyed-out ones, so the job summary
# says why it took a second: this job reports success having audited
# nothing, which is the intended behaviour for a pull request that
# changes none of its inputs.
if: steps.changes.outputs.client == 'false'
run: |
echo "No client, sas/mocks, sas/package.json or .gitea/scripts change in this pull request - the audit was skipped by design."
echo "The build job's 'Detect the Cypress job's inputs' step lists the files it saw, and warns instead if it could not decide (in which case the audit runs)."
- name: Check runner size
if: steps.changes.outputs.client != 'false'
# This job runs a SASjs server, Chrome and the full mock estate; an
# undersized runner dies inside one of them with exitcode 137. Fail here
# in seconds instead - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
if: steps.changes.outputs.client != 'false'
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
if: steps.changes.outputs.client != 'false'
run: |
# the registry secret arrives through the environment, not interpolated
# into this script
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install Google Chrome
if: steps.changes.outputs.client != 'false'
# lhci launches Chrome itself; --no-sandbox comes from
# client/lighthouserc.js, which is what makes it work as root.
run: |
apt-get update
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt install -y ./google-chrome*.deb
- name: Install dependencies
if: steps.changes.outputs.client != 'false'
# Only the client's node_modules is needed here - lhci comes from it and
# nothing is compiled in this job, so the source generator (postinstall)
# and its node_modules edits are not run.
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | \
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc), and nothing here needs
# what they would generate
npm ci --ignore-scripts
- name: Fetch the production build
if: steps.changes.outputs.client != 'false'
# Every audited URL is on the copy the SASjs server streams from
# client/dist, so this audits exactly the bytes the build job produced
# instead of compiling a second, possibly different, copy of them.
uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Cache the npm downloads
if: steps.changes.outputs.client != 'false'
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Install global packages
if: steps.changes.outputs.client != 'false'
run: |
# pinned, so what runs here is a release this repo chose: the CLI at
# the version sas/package.json pins, pm2 at the version named here
CLI_VERSION=$(node -p "require('./sas/package.json').dependencies['@sasjs/cli']")
npm i -g pm2@7.1.2
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Setup and start SASjs server
if: steps.changes.outputs.client != 'false'
run: |
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
# No CORS: the audited app is served by this same server (:5000), so
# every call it makes is same-origin. The Cypress job needs CORS
# because its browser is on ng serve's :4200; this job has no dev
# server, and the server refuses to start with CORS=enable and no
# WHITELIST.
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account (a
# security change in the recent releases). This job runs as root in a
# throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
exit 0
fi
sleep 2
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
exit 1
- name: Deploy JS mocked services and frontend to the SASjs server
if: steps.changes.outputs.client != 'false'
# `cd sas/mocks/sasjs` is explicit: sas/mocks holds no sasjsconfig.json,
# and running from there made the CLI search for the project while its
# `npm ci` installed whatever package.json the prefix walk found. The
# server-ci target deploys to /Public/app/dc, which is the appLoc the
# production build already carries, so the audited app calls the
# services this step creates with no attribute patching in between.
run: |
test -f client/dist/index.html || { echo "the artifact carries no client/dist/index.html"; ls -la client/dist; exit 1; }
cd sas/mocks/sasjs
# This server is fresh in this container, so there is no previous
# deployment to delete - cbd is the first thing to write the appLoc.
sasjs cbd -t server-ci
# Seed the DC database and the Cypress fixtures, exactly as the Cypress
# job does: most audited routes are data views (tables, view/data, the
# review lists, excel-maps, multi-load), and an unseeded estate renders
# empty or error states instead of the app users get. The payload asks
# for OUTFMT=JSON so a failed seed fails this job; createdemodata runs
# after it because it needs the control library makedata creates.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks), not
# the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
- name: Run Lighthouse CI
if: steps.changes.outputs.client != 'false'
# One run per URL: the assertions aggregate by median, so three runs
# bought a steadier median at three times the navigations (13.6 minutes
# measured). A pull request gate only needs to know the page still
# clears the thresholds it cleared before.
run: |
cd client
npx lhci autorun
- name: Publish the scores to the pull request
# One comment, rewritten in place on every push: the marker in the body is
# how the script finds the previous run's comment again, so a re-run
# replaces the old table rather than adding another one. It runs on
# failure too - a red audit is exactly when the scores are worth reading -
# and a comment that cannot be posted never fails the job, because the
# audit's own step carries the verdict.
if: always() && steps.changes.outputs.client != 'false' && github.event_name == 'pull_request'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .gitea/scripts/lighthouse-report.mjs
- name: Lighthouse Result Artifacts
if: always() && steps.changes.outputs.client != 'false'
# v3, not v4: this runner reports "GHESNotSupportedError" for the
# artifact action's current major. No `include-hidden-files` input -
# that arrived with v4.4 and v3 only warns about it as an unexpected
# input, which reads as a misconfiguration in every run's log.
uses: actions/upload-artifact@v3
with:
name: Lighthouse results
path: client/lighthouse-reports
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
name: cypress-videos.zip
path: cypress-videos.zip
-130
View File
@@ -1,130 +0,0 @@
name: Publish code documentation
run-name: Publishing typedoc and SAS docs
# The code documentation sites are published here rather than inline in the
# release job, so that they can be republished on their own - after a secret
# rotation, or for a release whose artefact upload failed (the release job
# cannot simply be re-run: semantic-release refuses once the tag exists).
on:
workflow_dispatch:
env:
NODE_VERSION: '24.15.0'
jobs:
typedoc:
runs-on: ubuntu-latest
# One deploy per site at a time: `surfer deploy` swaps the site atomically,
# so two runs racing is not corruption, but it is a wasted build and the
# last swap wins.
concurrency:
group: surfer-webdoc
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC }}
- name: Install dependencies
run: |
cd client
# Decrypt and Install sheet
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
shell: bash
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
- name: Build typedoc
run: |
cd client
npm run compodoc:build
# Deploy tooling is pinned to an exact version: an unpinned `latest` would
# let a breaking or compromised upstream release publish straight to the
# live site. `deploy` (v7) replaces the site atomically, and it takes the
# Cloudron username plus an app password - the v6 `put --token` flow this
# replaces is gone.
#
# webdoc is a NAMED site on the app whose default site is the marketing
# site, so the name and its alias domain are both passed. Surfer picks
# the target only from `--site-name` (it travels as the Surfer-Deployment
# header); without it `deploy` replaces the app's DEFAULT site, which is
# what serves datacontroller.io - so an unnamed deploy published the
# Typedoc over the top of the marketing site instead of to webdoc.
- name: Deploy typedoc
env:
SURFER_PASSWORD: ${{ secrets.WEBDOC_DATACONTROLLER_IO }}
REF_NAME: ${{ gitea.ref_name }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
npx --yes @cloudron/surfer@7.2.0 deploy client/documentation \
--server webdoc.datacontroller.io \
--site-name webdoc \
--domain webdoc.datacontroller.io \
--username allan \
--password "$SURFER_PASSWORD" \
--message "Typedoc from ${REF_NAME} @ ${COMMIT_SHA:0:8}"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
sas-docs:
runs-on: ubuntu-latest
concurrency:
group: surfer-code-datacontroller
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: |
# `sasjs doc` shells out to doxygen
apt-get update
apt-get install -y doxygen
cd sas
npm ci
CLI_VERSION=$(node -p "require('./package.json').dependencies['@sasjs/cli']")
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Build SAS docs
run: |
cd sas
sasjs doc
# No `--site-name` here on purpose: code.datacontroller.io is its own app
# and the SAS docs are its DEFAULT site, so naming one would create a
# separate site and leave the served copy stale. Do not copy the named
# form from the typedoc job above - that app's default site is the
# marketing site, which is why it must not be the target.
- name: Deploy SAS docs
env:
SURFER_PASSWORD: ${{ secrets.WEBDOC_DATACONTROLLER_IO }}
REF_NAME: ${{ gitea.ref_name }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
npx --yes @cloudron/surfer@7.2.0 deploy sas/sasjsbuild/sasdocs \
--server code.datacontroller.io \
--username allan \
--password "$SURFER_PASSWORD" \
--message "SAS docs from ${REF_NAME} @ ${COMMIT_SHA:0:8}"
+91
View File
@@ -0,0 +1,91 @@
name: Lighthouse Checks
run-name: Running Lighthouse Performance and Accessibility Checks on Pull Request
on: [pull_request]
env:
NODE_VERSION: '24.15.0'
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Google Chrome
run: |
apt-get update
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt install -y ./google-chrome*.deb
- name: Install global packages
run: npm i -g pm2 @sasjs/cli wait-on
- name: Setup and start SASjs server
run: |
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
cat .env
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
pm2 start api-linux --wait-ready
- name: Write .npmrc file
run: echo "$NPMRC" > client/.npmrc
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install npm dependencies
run: |
cd client
# Decrypt and Install sheet
echo "${{ secrets.SHEET_PWD }}" | \
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm ci
npm install -g replace-in-files-cli
- name: Update appLoc in index.html
run: |
cd client
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
- name: Build Frontend
run: |
cd client
npm run build
- name: Deploy JS mocked services and frontend to the local SASjs Server instance
run: |
cd sas/mocks
npm ci
sasjs cbd -t server-ci
- name: Start frontend server
run: |
cd client
npx ng serve --host 0.0.0.0 --port 4200 &
wait-on http://localhost:4200
- name: Run Lighthouse CI
run: |
cd client
npx lhci autorun
- name: Lighthouse Result Artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: Lighthouse results
path: client/lighthouse-reports
include-hidden-files: true
-225
View File
@@ -1,225 +0,0 @@
name: Release assets
run-name: Building and uploading the release assets
# Builds the downloadable artefacts for an existing release and uploads them.
#
# The release job cuts the tag before it builds, so a failure anywhere between
# the semantic-release step and the asset upload leaves a published release with
# no files - and that job cannot simply be re-run, because semantic-release
# refuses once the tag exists. This workflow rebuilds the artefacts from the tag
# and attaches them, so the release can be completed without cutting a new one.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and upload (blank = the latest release)'
required: false
default: ''
env:
NODE_VERSION: '24.15.0'
permissions:
contents: write
jobs:
assets:
runs-on: ubuntu-latest
# Two runs uploading the same asset names would race the delete-then-upload
# below.
concurrency:
group: release-assets
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check runner size
# Builds the same frontend as the release jobs; an undersized runner
# fails inside the build - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC }}
- name: Install system packages
# jq parses the release JSON below, so it is installed before the tag
# resolve.
run: |
apt-get update
apt-get install -y zip jq
- name: Resolve the release tag
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REQUESTED_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
if [ -n "${REQUESTED_TAG}" ]; then
TAG="${REQUESTED_TAG}"
# Fail here rather than after a ten-minute build if the tag is not
# cut yet or has no release.
curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${TAG}" > /dev/null
else
TAG=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest" | jq -r '.tag_name')
fi
echo "Building assets for ${TAG}"
echo "TAG=${TAG}" >> "$GITHUB_ENV"
- name: Check out the tag
run: |
set -euo pipefail
git fetch --force --tags origin "+refs/tags/${TAG}:refs/tags/${TAG}"
git checkout --force "refs/tags/${TAG}"
- name: Install the SASjs CLI the tag pins
run: |
cd sas
CLI_VERSION=$(node -p "require('./package.json').dependencies['@sasjs/cli']")
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Frontend build
run: |
cd client
# Decrypt and Install sheet
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
# webpack comes from the client lockfile, so install the version it
# already resolves rather than whatever is current at release time
WEBPACK_VERSION=$(node -p "require('./package-lock.json').packages['node_modules/webpack'].version")
npm i --no-save "webpack@${WEBPACK_VERSION}"
npm run build
shell: bash
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
- name: Build SAS9 EBI Release
run: |
cd sas
npm i
sasjs c -t sas9
rm -rf sasjsbuild/tests
sasjs b -t sas9
cp sasjsbuild/mysas9deploy.sas ./demostream_sas9.sas
# remove streamed component and rebuild SAS 9 services
rm -rf sasjsbuild/services/web9
rm sasjsbuild/services/clickme.sas
sasjs b -t sas9
cp sasjsbuild/mysas9deploy.sas ./sas9.sas
- name: Build SASjs Server Release
run: |
cd sas
cp sasjs/utils/favicon.ico ../client/dist/favicon.ico
sasjs c -t server
rm -rf sasjsbuild/tests
server_apploc="/Public/app/dc"
sed -i "s|apploc=\"[^\"]*\"|apploc=\"${server_apploc}\"|g" sasjsbuild/services/web/index.html
sasjs b -t server
cp sasjsbuild/server.json.zip ./sasjs_server.json.zip
- name: Build Viya Release
run: |
cd sas
sasjs c -t viya
rm -rf sasjsbuild/tests
sed -i -e 's/servertype="SASJS"/servertype="SASVIYA"/g' sasjsbuild/services/DC.html
sasjs b -t viya
cp sasjsbuild/viya.sas ./viya.sas
# compile Viya Full deploy (without web)
rm -rf sasjsbuild/services/web
rm sasjsbuild/services/DC.html
sasjs b -t viya
cp sasjsbuild/viya.sas ./viya_noweb.sas
cp sasjsbuild/viya.json ./viya_noweb.json
- name: Zip Frontend (including viya.json for full viya deploy)
run: |
cd sas
cp sasjsbuild/viya.json ../client/dist/viya.json
# Zip from *inside* dist so the archive holds the frontend files at its
# root rather than under a client/dist/ prefix (see #147).
cd ../client/dist
zip -r ../../frontend.zip .
- name: Upload assets to the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
ASSETS=(frontend.zip
sas/demostream_sas9.sas
sas/viya.sas
sas/sasjs_server.json.zip
sas/sas9.sas
sas/viya_noweb.sas
sas/viya_noweb.json)
: > SHA256SUMS
for f in "${ASSETS[@]}"; do
sum=$(sha256sum "$f" | cut -d' ' -f1)
printf '%s %s\n' "$sum" "${f##*/}" >> SHA256SUMS
done
cat SHA256SUMS
# Replacing the assets means removing the ones this workflow uploads,
# and a second run must not append the verification section twice. An
# asset the release carries from another source is left alone.
OWNED=$(printf '%s\n' "${ASSETS[@]##*/}" SHA256SUMS)
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.id) \(.name)"' |
while read -r asset_id asset_name; do
if printf '%s\n' "$OWNED" | grep -qxF -- "$asset_name"; then
echo "Removing previous ${asset_name}"
curl --fail-with-body -sS -X DELETE -H "$AUTH_HEADER" "$BASE/releases/${RELEASE_ID}/assets/${asset_id}"
else
echo "Leaving ${asset_name} in place: this workflow did not upload it"
fi
done
if echo "$RELEASE_BODY" | grep -q "## Verifying the download"; then
echo "Release body already carries the verification section"
else
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
NEW_BODY=$(jq -nr --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
'$body + "\n" + $verify + "\n\nFor installation instructions, please visit https://docs.datacontroller.io/"')
curl --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
"$BASE/releases/$RELEASE_ID"
fi
URL="$BASE/releases/${RELEASE_ID}/assets"
for f in "${ASSETS[@]}"; do
echo "Uploading $f ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
echo "Uploading SHA256SUMS ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
+57 -263
View File
@@ -6,11 +6,7 @@ on:
- main
env:
# the server the mocked services and app tests run against - bump deliberately
# (build.yaml carries the full rationale; this workflow runs the same mocked
# estate, so it stays on the same pin).
SASJS_SERVER_VERSION: '4.14.0'
NODE_VERSION: '24.15.0'
NODE_VERSION: '24.5.0'
jobs:
Build-production-and-ng-test:
@@ -20,24 +16,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# A release job that OOMs costs the tag as well as the slot; fail in
# seconds on an undersized runner instead of twenty minutes in - see
# the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install Chrome for Angular tests
run: |
@@ -52,16 +38,11 @@ jobs:
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
- name: Install dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
@@ -81,14 +62,8 @@ jobs:
run: |
cd client
npm run postinstall
npm run check:archiver
npm run build
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
Build-and-test-development:
runs-on: ubuntu-latest
needs: Build-production-and-ng-test
@@ -100,23 +75,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# The development build plus a browser: same undersized-runner failure
# as the review jobs, on the path that makes the tag - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install system dependencies
run: |
@@ -135,146 +101,62 @@ jobs:
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
- name: Install dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Build frontend for web streaming
# The server-ci target streams client/dist as the 'clickme' web app, so
# the production build has to exist before the mocked services are
# deployed. Without it `sasjs cbd` reports
# "webSourcePath: .../client/dist ... doesn't exist", streams no
# frontend and the deploy step fails.
run: |
cd client
npm run build
- name: Cache the npm downloads
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Setup and start SASjs server
run: |
npm i -g pm2@7.1.2
npm i -g pm2
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
exit 0
fi
sleep 2
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
exit 1
pm2 start api-linux --wait-ready
- name: Deploy mocked services
run: |
cd ./sas/mocks/sasjs
# The CLI comes from the version sas/package.json pins, as docs.yaml and
# release-assets.yaml already do: an unpinned global install runs
# whatever release is current, unreviewed.
CLI_VERSION=$(node -p "require('../../package.json').dependencies['@sasjs/cli']")
npm install -g "@sasjs/cli@${CLI_VERSION}"
npm install -g replace-in-files-cli@4.0.0
# Remove any previous deployment (drive folder delete API) so the
# deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
npm install -g @sasjs/cli
npm install -g replace-in-files-cli
sasjs cbd -t server-ci
# Seed the DC database (mock data files on the drive). The payload
# asks for OUTFMT=JSON, so the CLI parses the response and a failed
# seed fails the job - createdemodata runs next and would only report
# a missing control library.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
# Then the Cypress fixtures: TESTDATA, the search/wide viewers and the
# multi-load library. makedata builds only what a real deployment
# builds, so the specs need this second service.
sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
- name: Prepare and run frontend and cypress
env:
CYPRESS_USERNAME: ${{ secrets.CYPRESS_USERNAME_SASJS }}
CYPRESS_PWD: ${{ secrets.CYPRESS_PWD_SASJS }}
run: |
cd ./client
mv ./cypress.env.example.json ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'"$CYPRESS_USERNAME"'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'"$CYPRESS_PWD"'" ' ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'${{ secrets.CYPRESS_USERNAME_SASJS }}'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
cat ./cypress.env.json
npm run postinstall
# cypress downloads its binary in a dependency install hook, which the
# client policy skips
npx cypress install
# Prepare index.html to SASJS local
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
replace-in-files --regex='serverType=".*?"' --replacement='serverType="SASJS"' ./src/index.html
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
#
# video=false, screenshotOnRunFailure=true: recording these eleven
# specs costs encode time on every run, and the recording is only ever
# opened after a failure. A screenshot of the failing step carries the
# same information for a fraction of the cost - and cypress.config.ts
# sets screenshotOnRunFailure false by default, so both are passed
# here, as CLI overrides, leaving local runs (and the demo recorder,
# which sets RECORD_WINDOW_SIZE) on video.
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --config video=false,screenshotOnRunFailure=true --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
- name: Zip Cypress failure evidence
# Only on a failure: a green run has nothing to look at.
if: failure()
- name: Zip Cypress videos
if: always()
run: |
mkdir -p ./client/cypress/screenshots ./client/cypress/videos
zip -r cypress-artifacts.zip ./client/cypress/screenshots ./client/cypress/videos
mkdir -p ./client/cypress/videos
zip -r cypress-videos ./client/cypress/videos
- name: Add cypress failure evidence as an artifact
if: failure()
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all.
- name: Add cypress videos artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-artifacts.zip
path: cypress-artifacts.zip
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
name: cypress-videos.zip
path: cypress-videos.zip
release:
runs-on: ubuntu-latest
@@ -284,31 +166,22 @@ jobs:
# and its permissions are derived from this block.
# contents: write -> push CHANGELOG.md / package.json commit + tag, and
# create the Release via the Gitea API.
# actions: write -> dispatch the Sites workflow for the new tag (that
# route is scoped to the actions unit, which contents
# does not grant).
# The TSDOC_TOKEN and CODE_DATACONTROLLER_IO secrets continue to be used
# because they target *different* servers (webdoc.datacontroller.io and
# code.datacontroller.io), not the Gitea API.
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@v4
- name: Check runner size
# This job builds the frontend twice (webdoc and the release check);
# both die the same way on an undersized runner - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
echo "$NPMRC" > client/.npmrc
echo "legacy-peer-deps=true" >> client/.npmrc
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
@@ -321,16 +194,11 @@ jobs:
- name: Frontend Preliminary Build
description: We want to prevent creating empty release if frontend fails
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
npm i webpack
npm run build
@@ -371,11 +239,6 @@ jobs:
semantic-release
# The tag this job cuts, for the steps that publish against it: a
# `releases/latest` lookup would pick whichever release landed last
# if two overlap.
echo "RELEASE_TAG=v$(node -p "require('./package.json').version")" >> "$GITHUB_ENV"
- name: Frontend Build
description: Must be created AFTER the release as the version (git tag) is used in the interface
run: |
@@ -431,10 +294,21 @@ jobs:
run: |
cd sas
cp sasjsbuild/viya.json ../client/dist/viya.json
# Zip from *inside* dist so the archive holds the frontend files at its
# root rather than under a client/dist/ prefix (see #147).
cd ../client/dist
zip -r ../../frontend.zip .
cd ..
zip -r frontend.zip ./client/dist
- name: Release Typedoc
run: |
cd client
npm -g install cloudron-surfer
npm run compodoc:build
surfer put --token ${{ secrets.TSDOC_TOKEN }} --server webdoc.datacontroller.io documentation/* /
- name: Release code.datacontroller.io
run: |
cd sas
sasjs doc
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
- name: Upload assets to release
env:
@@ -450,107 +324,27 @@ jobs:
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${RELEASE_TAG}")
RELEASE_JSON=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
# Generate SHA-256 checksums over the release assets, so downloads can
# be verified. The file uses basename-only paths so that a plain
# "sha256sum -c SHA256SUMS" works in the folder the assets were
# downloaded to (the upload below stores them under their basename).
ASSETS=(frontend.zip
sas/demostream_sas9.sas
sas/viya.sas
sas/sasjs_server.json.zip
sas/sas9.sas
sas/viya_noweb.sas
sas/viya_noweb.json)
: > SHA256SUMS
for f in "${ASSETS[@]}"; do
sum=$(sha256sum "$f" | cut -d' ' -f1)
printf '%s %s\n' "$sum" "${f##*/}" >> SHA256SUMS
done
cat SHA256SUMS
# Update body: keep the existing notes, add a verification section
# ahead of the installation footer. The section text lives in a
# dedicated script below (.gitea/scripts/verify-section.sh) so this
# YAML scalar stays clean.
# `jq -r` is required: without it jq emits the result as a JSON string
# literal (quoted, with \n escapes), and the PATCH below then encodes
# that literal a second time, so the release body renders as one
# escaped line instead of markdown.
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
NEW_BODY=$(jq -nr --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
'$body + "\n" + $verify + "\n\nFor installation instructions, please visit https://docs.datacontroller.io/"')
curl --fail-with-body -sS -X PATCH \
# Update body (also confirms the token has contents:write on this repo)
curl -k --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
--data "$(jq -n --arg body "$RELEASE_BODY"$'\n\nFor installation instructions, please visit https://docs.datacontroller.io/' \
'{draft:false, body:$body}')" \
"$BASE/releases/$RELEASE_ID"
# Upload assets
URL="$BASE/releases/$RELEASE_ID/assets"
for f in "${ASSETS[@]}"; do
for f in frontend.zip \
sas/demostream_sas9.sas \
sas/viya.sas \
sas/sasjs_server.json.zip \
sas/sas9.sas \
sas/viya_noweb.sas \
sas/viya_noweb.json; do
echo "Uploading $f ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
echo "Uploading SHA256SUMS ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Publish the code documentation
# Typedoc (webdoc) and the SAS docs (code) are published by docs.yaml,
# not inline here. Two reasons: the v6 `surfer put --token` flow is gone
# in Surfer 7, which takes a Cloudron username plus an app password; and
# a dispatch means a stale or failed docs deploy can be republished
# without re-running this job, which semantic-release refuses once the
# tag exists.
#
# This runs AFTER the release assets are uploaded: the documentation is
# a side channel, and a docs problem must not cost a cut release its
# artefacts.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
TAG="${RELEASE_TAG}"
echo "Dispatching code documentation for ${TAG}"
curl --fail-with-body -sS -X POST \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
"$BASE/actions/workflows/docs.yaml/dispatches"
- name: Publish the demo site for this release
# The tag is created by this job's semantic-release step, so it carries
# the Actions user as its author - and the forge suppresses ref events
# from that user, which means `on: push: tags` in sites.yaml never fires.
# Dispatch it instead: the API call is authenticated, and the run it
# starts checks out the tag the same way.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
TAG="${RELEASE_TAG}"
echo "Dispatching Sites for ${TAG}"
# the dispatch endpoint resolves a bare name as a branch; a tag needs
# the refs/tags/ prefix
curl --fail-with-body -sS -X POST \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
"$BASE/actions/workflows/sites.yaml/dispatches"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
-251
View File
@@ -1,251 +0,0 @@
name: Sites
on:
push:
branches:
- 'release/**'
tags:
- 'v*'
paths-ignore:
- '**.md'
workflow_dispatch:
env:
# the server the mocked services and app tests run against - bump deliberately
# (build.yaml carries the full rationale; this workflow runs the same mocked
# estate, so it stays on the same pin).
SASJS_SERVER_VERSION: '4.14.0'
NODE_VERSION: '24.15.0'
APP_LOC: /Public/app/dc
SURFER_HOST: https://demo.datacontroller.io
# A release branch publishes the staging site (a named site on the instance,
# served at its alias domain); a release tag publishes the demo, which is the
# default site and so needs no site name.
STAGING_SITE: staging
STAGING_DOMAIN: staging.datacontroller.io
# One deploy per target site: a newer push supersedes the one in flight, and two
# release branches (which share the staging site) cannot interleave their swaps.
# The demo and staging deploys are independent, so they do not queue.
concurrency:
group: surfer-${{ startsWith(gitea.ref, 'refs/tags/') && 'demo' || 'staging' }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The tag is what the client's build stamp describes (src/version.ts
# runs git-describe), so the demo build needs the tags in the clone.
fetch-depth: 0
- name: Check runner size
# The demo build is the same cold production build; an undersized
# runner fails inside it - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install client dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Build frontend
# The site serves this build. `sasjs build` rewrites its index.html to
# point the adapter at the in-browser backend.
run: |
cd client
npm run postinstall
npm run build
- name: Install the SASjs CLI
# Pinned in sas/package.json - the browser build needs CLI >= 6.1.0
run: |
cd sas
npm ci
- name: Cache the npm downloads
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Start SASjs server
# Needed only to compile and deploy the mocked services, and - for a tag -
# to seed the drive the demo ships with (see the two steps below).
run: |
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
echo RUN_AS=root >> .env
nohup npx --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }} > server.log 2>&1 &
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
exit 0
fi
sleep 2
done
echo "SASjs server did not come up within 120s"
cat server.log
exit 1
- name: Deploy the mocked services
run: |
cd sas/mocks/sasjs
# Start from a clean appLoc so the site below carries this build alone,
# not a merge with whatever a previous run left on the drive.
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=$APP_LOC"
../../node_modules/.bin/sasjs cbd -t server-ci
- name: Seed the demo drive
# A tag publishes the demo, the site a visitor can use straight away, so
# its drive is seeded here and captured for publishing. A release branch
# publishes staging, which is deliberately left unconfigured (see the
# step below) so it opens on the deploy screen.
if: ${{ startsWith(gitea.ref, 'refs/tags/') }}
run: |
cd sas/mocks/sasjs
# the payload asks for OUTFMT=JSON, so a failed seed fails this step
# rather than being published as an empty site (the drive is checked
# as well, before the snapshot is taken)
../../node_modules/.bin/sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
# the sample tables the demo site opens with are a separate service
# now, so that makedata matches what a SAS deployment builds
../../node_modules/.bin/sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
DATA_DIR="../../../sasjs_root/drive/files$APP_LOC/data"
SEEDED=$(find "$DATA_DIR" -name '*.json' 2>/dev/null | wc -l)
echo "makedata seeded $SEEDED table(s) under $DATA_DIR"
if [ -z "$(find "$DATA_DIR" -name 'mpe_tables.json' 2>/dev/null | head -1)" ] || [ "$SEEDED" -lt 10 ]; then
echo "makedata did not seed the drive - refusing to publish a data-less site"
find ../../../sasjs_root/drive/files -maxdepth 3 2>/dev/null | head -20
exit 1
fi
- name: Check the staging drive is unconfigured
# Staging exists to show the deployment, so a visitor has to land on the
# deploy screen rather than on a control library. DC routes there while
# makedata is still on the drive, and stops once the wizard has run it
# and it has removed itself - so the drive is shipped with makedata and
# without any seeded tables. Seeding staging by accident would only be
# visible as a site that opens on the home screen, so it is refused here.
if: ${{ !startsWith(gitea.ref, 'refs/tags/') }}
run: |
ADMIN_DIR="sasjs_root/drive/files$APP_LOC/services/admin"
DATA_DIR="sasjs_root/drive/files$APP_LOC/data"
if [ -z "$(find "$ADMIN_DIR" -name 'makedata.js' 2>/dev/null | head -1)" ]; then
echo "makedata is not on the drive - staging would not offer the deploy screen"
find "$ADMIN_DIR" -maxdepth 1 2>/dev/null | head -20
exit 1
fi
if [ -n "$(find "$DATA_DIR" -name 'mpe_tables.json' 2>/dev/null | head -1)" ]; then
echo "the staging drive is seeded - staging would open on the home screen"
exit 1
fi
echo "staging ships an unconfigured drive: makedata present, no control library"
- name: Build the browser-hosted site
run: |
# The published sites ship a working licence so they are usable beyond
# the free tier. The pair is committed in sas/mocks/mock-licence.json
# rather than minted here, so every deploy of both sites carries the
# same key; the CI estate stays unlicensed because that file is outside
# the deployed sync folder, so the specs still exercise the free tier.
# The pair must round trip through the same WebCrypto the app uses,
# because a committed pair can drift (one half regenerated, a value
# truncated on copy) and nothing else would notice before publishing.
# Identical values in the two fields is the HTTP-format marker the app
# refuses in a secure context, so refuse to publish one.
node -e "
const d = require('./sas/mocks/mock-licence.json')
const b = (s) => Buffer.from(String(s || ''), 'base64')
if (!d.licenceKey || !d.activationKey || d.licenceKey === d.activationKey) {
console.error('sas/mocks/mock-licence.json must hold an HTTPS-format key pair')
process.exit(1)
}
crypto.subtle
.importKey('pkcs8', b(d.activationKey), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'])
.then((key) => crypto.subtle.decrypt({ name: 'RSA-OAEP', hash: 'SHA-256' }, key, b(d.licenceKey)))
.then((plain) => {
const data = JSON.parse(new TextDecoder().decode(plain))
console.log('licence pair round trips: site', data.site_id, 'until', data.valid_until)
})
.catch((e) => {
console.error('sas/mocks/mock-licence.json does not decrypt:', e.message || e)
process.exit(1)
})
"
LICENCE_DIR="sasjs_root/drive/files$APP_LOC/mock-storage"
mkdir -p "$LICENCE_DIR"
cp sas/mocks/mock-licence.json "$LICENCE_DIR/licence.json"
# The drive is captured and shipped as the site's starting drive: for the
# demo that is a seeded control library; for staging it is the services
# and the licence only, with makedata still on it so the app opens on
# the deploy screen. services/web9 is the streamed frontend, which is
# already the site itself.
node sas/mocks/snapshot.js sasjs_root/drive "$APP_LOC" sas/mocks/sasjsbuild/mock-snapshot.json services/web9
cd sas/mocks/sasjs
../../node_modules/.bin/sasjs b -t server-ci
- name: Publish to Surfer
env:
SURFER_USER: allan
SURFER_PASSWORD: ${{ secrets.DEMO_DATACONTROLLER_IO }}
REF: ${{ gitea.ref }}
MESSAGE: Build from ${{ gitea.ref_name }} @ ${{ gitea.sha }}
run: |
# `deploy` uploads the folder as an archive and swaps the site in one
# step, so a failed upload leaves the current site serving.
if [ -z "$REF" ]; then
echo "REF is empty - cannot tell a branch push from a tag push"
exit 1
fi
SITE_ARGS=()
case "$REF" in
refs/tags/*) ;;
*) SITE_ARGS=(--site-name "$STAGING_SITE" --domain "$STAGING_DOMAIN") ;;
esac
npx --yes @cloudron/surfer@7.2.0 deploy \
--server "$SURFER_HOST" \
--username "$SURFER_USER" \
--password "$SURFER_PASSWORD" \
--message "$MESSAGE" \
"${SITE_ARGS[@]}" \
sas/mocks/sasjsbuild/mock
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
+2 -5
View File
@@ -20,9 +20,6 @@ sasjsbuild
sasjsresults
.env*
.sasjsrc
client/.npmrc
*~
.lighthouseci
audit.log
tmp/
# raw parity captures - only the projection in parity/golden is committed
sas/mocks/test/parity/.captures/
.lighthouseci
-5
View File
@@ -1,5 +0,0 @@
# sas/mocks/mock-licence.json holds a licence key pair for the mock estate: the
# activation key is RSA key material the browser demo decrypts with, not a
# credential for any service.
sas/mocks/mock-licence.json:generic-api-key:2
sas/mocks/mock-licence.json:generic-api-key:3
-16
View File
@@ -1,16 +0,0 @@
# Hooks for the pre-commit framework - https://pre-commit.com
#
# The repo also ships native hooks in .git-hooks/, covering the secret scan, a
# 2MB commit size limit, a prettier check and Conventional Commits. But .npmrc
# sets ignore-scripts=true, so the `prepare` script that points git at them
# never runs on `npm i` - a fresh clone commits unchecked until the one-time
# command in CONTRIBUTING.md is run by hand.
#
# This file is the standard route instead: install the framework once with
# `pre-commit install`, and the secret scan runs on every commit. The hook is
# pinned to an exact gitleaks release tag.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
+5 -12
View File
@@ -7,7 +7,7 @@ Read **`CONTEXT.md`** at the repo root first - it is the domain glossary and ori
Data Controller spans three sibling repos (usually checked out side by side):
- **`dc`** (this repo) - the product source (Angular client + SAS backend).
- **`docs.datacontroller.io`** - the user-facing product documentation (MkDocs). User-facing behaviour is documented there; the `.agents/docs/` deep-dives here link out to it.
- **`docs.datacontroller.io`** - the user-facing product documentation (MkDocs). User-facing behaviour is documented there; the `.agent/docs/` deep-dives here link out to it.
- **`datacontroller.io`** - the marketing site, blog and feed (Gatsby).
## Git
@@ -16,7 +16,7 @@ Do NOT auto-commit. Never run `git commit` (or `git push`) unless the user expli
## CHANGELOG and versioning
Do NOT edit `CHANGELOG.md` by hand, and do NOT bump the `version` in `package.json`. The release pipeline (semantic-release, in `.gitea/workflows/release.yaml`) generates both automatically from Conventional Commit messages on every push to `main`. Manual entries collide with the pipeline's release commit and are overwritten anyway. Control the changelog through your commit messages instead. See `.agents/docs/releases-and-changelog.md`.
Do NOT edit `CHANGELOG.md` by hand, and do NOT bump the `version` in `package.json`. The release pipeline (semantic-release, in `.gitea/workflows/release.yaml`) generates both automatically from Conventional Commit messages on every push to `main`. Manual entries collide with the pipeline's release commit and are overwritten anyway. Control the changelog through your commit messages instead. See `.agent/docs/releases-and-changelog.md`.
## Markdown files
@@ -31,22 +31,15 @@ Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
- **SAS**: after creating or modifying any `.sas` files, run `npm run lint:sas` from the `sas/` directory. That script runs the `@sasjs/cli` version pinned in `sas/package.json`, so it matches CI; a global or `npx` `sasjs` may resolve a different `@sasjs/lint` and report a different rule set.
Rules are promoted to `error` in `sas/.sasjslint` one at a time as their findings reach zero, and only errors change the exit code - so the whole project must stay clean of the promoted rules, not just the files you touched. Warnings under rules not yet promoted are the remaining backlog; do not add to them in files you touch, and do not clear other files' findings in the same change unless that rule is the point of the change.
`sas/.sasjslint` carries `ignoredMacros: ["webout", "mpeinit", "mpeterm"]`. `webout` is the linter's own default - `sasjs compile` injects `%webout` into every service - and an explicit list replaces that default, so it has to be restated there. `mpeinit` and `mpeterm` are listed because every service is compiled with a serviceInit program (`sasjs/utils/serviceinitsas9|viya|server.sas`) that declares both macros in its own header, so a service body may call them without declaring them. Add a macro to that list only when it reaches a file the same way - injected by the compiler, through serviceInit, testSetUp or another implicit provider - never to silence a genuinely missing declaration.
`noBannedStrings` flags the literal words `error ` and `warning ` anywhere in a file, comments included. Reword prose; where the text has to stay (a real `%put`, a doxygen `@warning` tag), keep the log output identical with `%put %str(WARN)ING:` rather than a per-file override, and use `@sasjslint {"noBannedStrings": false}` only when the word itself is the content.
Hook scripts (`sasjs/services/hooks/*.sas`) run inside the service that includes them, so the macros they call come from that service's session. They carry a file-local `@sasjslint {"ignoredMacros": [...]}` rather than a macro list of their own, because end users copy and edit these scripts and should not have to maintain a declaration section.
`noMacroIfInOpenCode` is on, so `%if` must sit inside a `%macro` definition. Where a conditional has to stay in a service body, wrap the block in a small macro and call it unconditionally (`%macro build_staged(); ... %end; %mend build_staged; %build_staged()`), or resolve the value in a data step with `call symputx`. Two traps: a `%let` inside such a wrapper is local, so a macro variable that must survive the block has to be written with `call symputx(...,'G')`; and open-code `%if` itself needs SAS 9.4M5, which the code no longer relies on.
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
## No external assets
Data Controller must run entirely locally (offline / on-prem, no internet access). The built product must never fetch assets from remote servers — no external fonts, images, scripts, stylesheets, CDN links, or remote URLs in meta tags (e.g. `og:image`, `og:url`, `itemprop="image"`). All assets must be bundled and served locally.
## The .agents folder
## The .agent folder
Agent-related content lives in `.agents/`: technical/agent-facing documentation goes in `.agents/docs/` (not `docs/`), and skills in `.agents/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agents/docs/`.
Skills installed via `npx skills add` are tracked in `skills-lock.json` and live under `.agents/skills/`. Repo-local custom skills (e.g. `dc-sas` for Data Controller SAS development, `dc-estate-testing` for estate deploys and test runs, `handsontable`, `hyperformula`) are kept here too. Avoid editing npx-managed skill files directly — use `npx skills update` instead.
Agent-related content lives in `.agent/`: technical/agent-facing documentation goes in `.agent/docs/` (not `docs/`), and skills in `.agent/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agent/docs/`.
## Code comments and test names
-495
View File
@@ -1,498 +1,3 @@
# [7.22.0](https://git.datacontroller.io/dc/dc/compare/v7.21.0...v7.22.0) (2026-11-05)
### Bug Fixes
* **browse:** accept legal folder names, cap the walk, and lock the read ([3572d56](https://git.datacontroller.io/dc/dc/commit/3572d56cfb0e438512fac202829c8409388b1dac))
* **build:** build the mock sites with a CLI whose runtime provides webout() ([b1eab98](https://git.datacontroller.io/dc/dc/commit/b1eab982006393bb23f8d19076a715055087d4fc))
* **build:** publish the changed-path decision and pin the matcher with tests ([ecc8702](https://git.datacontroller.io/dc/dc/commit/ecc87021c3e398f7d2b451971e9d618d3dd33d04))
* **build:** treat a sas/package.json bump as a Cypress input ([48df795](https://git.datacontroller.io/dc/dc/commit/48df79520217afbc55a5b7f6a67a500efc70db07))
* **client:** cancel a queued dropdown reposition on destroy ([590b841](https://git.datacontroller.io/dc/dc/commit/590b841f24329e6d1aefab881895f794fc3738ed))
* **client:** drop the synthetic window resize dispatches ([2535baa](https://git.datacontroller.io/dc/dc/commit/2535baa5f7ad169f48678df3f0f55ca18b5eaa9a))
* **client:** keep the ARIA pass on the viewbox and multi-dataset grids too ([cb30469](https://git.datacontroller.io/dc/dc/commit/cb30469e794b4fb4c87f5c382f36236b837e1855))
* **client:** restrict the table pass to Handsontable's own tables, add the viewer fallback ([46c6aec](https://git.datacontroller.io/dc/dc/commit/46c6aec37a836722fff61337fde4cab0d97723d8))
* **client:** scope the ARIA pass to each grid and drive it from the render hooks ([bf1e7d6](https://git.datacontroller.io/dc/dc/commit/bf1e7d6b1583ef32d492840c508d2a4b0fb1a1df))
* **client:** stop synthesising aria-labels; name the filter modal's submit in the test ([32d3d49](https://git.datacontroller.io/dc/dc/commit/32d3d49b59fd158c5790c9e1a4dd3cfc4f532877))
* **client:** strip only this grid's own wrapper, and pin the re-strip after a data load ([f282d1a](https://git.datacontroller.io/dc/dc/commit/f282d1a44bfe458c1e17415e1e3ba8b484f61518))
* **deploy:** ACTION cannot seed by default, and the probe stops overwriting ([e84cfaa](https://git.datacontroller.io/dc/dc/commit/e84cfaaabdf6fb91b0d58524f8a1ad7a4d2673f3))
* **deploy:** ask for the admin group before the staging directory on SASjs ([5c64691](https://git.datacontroller.io/dc/dc/commit/5c646913e453699ee8d5e7aedcae8b92b125ebde))
* **deploy:** await nothing, and encode the values in the makedata URL ([d67a0e5](https://git.datacontroller.io/dc/dc/commit/d67a0e5d5f06eac2d3aba220f43eb0c7f9a2e4af))
* **deploy:** drop the deprecated service-pack deploy from the setup screen ([59e09e2](https://git.datacontroller.io/dc/dc/commit/59e09e26a5f4432fbfb397287f1fdd4f656d761e))
* **deploy:** gate the browse actions on the settings file ([5eb8492](https://git.datacontroller.io/dc/dc/commit/5eb8492742a6ede252bc4f40dd33932700792718))
* **deploy:** make the folder picker work on both estates ([fb38309](https://git.datacontroller.io/dc/dc/commit/fb38309e98a6455ffe1d2cc501d5c1f9e0a694ba))
* **deploy:** match the documented SYSSCPL set and stop guessing at the cap ([5f338ff](https://git.datacontroller.io/dc/dc/commit/5f338ff43754ef58ba45c81d9929115c3f4715a3))
* **deploy:** read a URL-supplied ACTION before defaulting, and show locked folders ([addb4a5](https://git.datacontroller.io/dc/dc/commit/addb4a56ea4e7cac2bf92956fcc30b593bba7eee))
* **deploy:** stop the makedata poll outliving its screen, and align the release gates ([c808c41](https://git.datacontroller.io/dc/dc/commit/c808c41333a1dd46008c38fe60169585e03bfe68)), closes [#407](https://git.datacontroller.io/dc/dc/issues/407)
* **deploy:** unclamp the folder picker row and probe in the chosen context ([1330925](https://git.datacontroller.io/dc/dc/commit/133092502cf4832ab9df7472b690e646ed0765ff))
* **deps:** bump @sasjs/core to 7.0.1 so special missings can be restored ([3d2eefd](https://git.datacontroller.io/dc/dc/commit/3d2eefdd7b815e8dcff7989393a6055468859bbd)), closes [sasjs/core#499](https://git.datacontroller.io/sasjs/core/issues/499)
* **deps:** bump @sasjs/core to 7.1.3 so a filter abort names the clause ([c7c1815](https://git.datacontroller.io/dc/dc/commit/c7c181518cbdb209fc73830bb791e87186bcfc20))
* **editor:** a container resize must not clear an active sort ([5bd7b5d](https://git.datacontroller.io/dc/dc/commit/5bd7b5d724db1590d211c25fa6b5cd3568ead36c)), closes [#246](https://git.datacontroller.io/dc/dc/issues/246) [#246](https://git.datacontroller.io/dc/dc/issues/246)
* **editor:** drop the stale case hint, and narrow the strip to our own form ([bd2739c](https://git.datacontroller.io/dc/dc/commit/bd2739cde41f4e2132059d04b75f0e9ed4cf33d9))
* **editor:** one predicate for the CASE marker, and no stacking in the tooltip ([18b0546](https://git.datacontroller.io/dc/dc/commit/18b0546dcbe647a5a64209535c04132c4c68bd17))
* **embed:** a rejected mapping must not block the page ([d845b9e](https://git.datacontroller.io/dc/dc/commit/d845b9e9b74a87be907f2455e55dbf21c7dfdd36)), closes [dc/dc#404](https://git.datacontroller.io/dc/dc/issues/404)
* **embed:** read a month-name date from VA as a date, not a character value ([30cefcf](https://git.datacontroller.io/dc/dc/commit/30cefcf2dcfd78ccb963d86b36668248fec9fa1e))
* **getgroupmembers:** widen membername to $256 for long names ([196d6c9](https://git.datacontroller.io/dc/dc/commit/196d6c931d44527250f59dd1f89db259a24774ad))
* **lint:** enable noSingleAsteriskComments and noUnusedLibnames ([9071801](https://git.datacontroller.io/dc/dc/commit/9071801ac51ac1bc726c9430389d34bddc7cd1c0)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **lint:** make noBannedStrings an error and clear its 13 findings ([0b85f7b](https://git.datacontroller.io/dc/dc/commit/0b85f7bf1e59543ac195e37dd0a9e4c58b6befb6)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **lint:** make noUndeclaredMacros an error, and fix what it found ([af28a79](https://git.datacontroller.io/dc/dc/commit/af28a79bd56234f17545c5bc3df4969464779264)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **lint:** make noUnusedMacros an error and clear its 54 findings ([c8c0858](https://git.datacontroller.io/dc/dc/commit/c8c0858295f40ba734403ba24a4cae02b5464554)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **lint:** rename the short and mixed-case helper macros, and clear the last warnings ([7da67eb](https://git.datacontroller.io/dc/dc/commit/7da67eb17bccf2e6635d01a007872294b5d1708a)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **makedata:** let the browse actions live as long as the service does ([912fae4](https://git.datacontroller.io/dc/dc/commit/912fae448b55130db13b3e684ea66b846d3d51c7))
* **mocks:** 0 is a closed TX_TO, a libref boundary, and the union of the row keys ([67d9cfd](https://git.datacontroller.io/dc/dc/commit/67d9cfd1bd9eb9512ddd9d222221dcf674552d9f))
* **mocks:** abort like mp_abort, lock like mp_lockanytable, derive saslibs ([ca60208](https://git.datacontroller.io/dc/dc/commit/ca602082ffc69f4cfca2de878fbf842a57efc194))
* **mocks:** answer the deploy screen's DIRLIST and WRITECHECK actions ([e1c3c6e](https://git.datacontroller.io/dc/dc/commit/e1c3c6e8727d928a10ddd2838563d13efcf33ddc))
* **mocks:** derive startupservice's maps and timezone instead of hardcoding ([87fd46c](https://git.datacontroller.io/dc/dc/commit/87fd46c825f592927df0e172bdfc91f0d13f81ef))
* **mocks:** fold the audit window, and append MPE_AUDIT under its lock ([5ecc7be](https://git.datacontroller.io/dc/dc/commit/5ecc7bebec104ef451e3d405c4c1728e39d8c2b0))
* **mocks:** keep the OMS and path separators the captures lost ([7d9d466](https://git.datacontroller.io/dc/dc/commit/7d9d466c3e9e9863e897bd1f663664274e660153))
* **mocks:** one sasDatetimeNow, a documented TX_TO=0, and a shaped fallback export ([e53c50d](https://git.datacontroller.io/dc/dc/commit/e53c50d4e2b4b8e3b3097bd415f2e2bbf940793d))
* **mocks:** read hook columns in either case, and guard duplicates first ([349a2f3](https://git.datacontroller.io/dc/dc/commit/349a2f314cbcbf6c706e201ccb5faa3412f934f4))
* **mocks:** read the metadata member from the response, not the function's arity ([09e9f19](https://git.datacontroller.io/dc/dc/commit/09e9f192c5dbc64f2812c2bac90b61d3e1e7346a))
* **mocks:** refuse a runtime whose webout() cannot carry column metadata ([482435f](https://git.datacontroller.io/dc/dc/commit/482435f318a3bd0dd33df02c6f4dc6335d7a1a6e))
* **mocks:** resolve the audit table once, record lower-case md5, and report a failed audit write ([1a9f594](https://git.datacontroller.io/dc/dc/commit/1a9f59478b059ac59c284f45cdd08fa6428e430c))
* **mocks:** run approval hooks, as mpe_runhook does ([f0208bb](https://git.datacontroller.io/dc/dc/commit/f0208bb0b00cce7e6fa1e5015aa95b4286509bdb))
* **mocks:** separate a body read failure from a missing showmeta floor ([744c89b](https://git.datacontroller.io/dc/dc/commit/744c89b91972f08130797a2650daf49cce0e6eb7))
* **mpe_abortmessage:** decide once whether the payload was escaped ([af89126](https://git.datacontroller.io/dc/dc/commit/af89126acf38d9aeee320b5784df87cdd48148e8))
* **mpe_abortmessage:** read program and state by name, and trim the prefix ([68b89ca](https://git.datacontroller.io/dc/dc/commit/68b89caf63678ace2bde61414b5b4bac2d419a9d))
* **mpe_assert_abort:** anchor the cut on the whole escaped separator ([e74a7ba](https://git.datacontroller.io/dc/dc/commit/e74a7ba1153f83eb436235fa17a90826e09af81f))
* **mpe_assert_abort:** compare the hook's message, not the log extract ([8b426c2](https://git.datacontroller.io/dc/dc/commit/8b426c27fd37a071a3df0417bbda3e1ec2edc291))
* **mpe_assert_abort:** compare the unescaped message, cut at the last marker ([e68d221](https://git.datacontroller.io/dc/dc/commit/e68d221354a3ab94d2975ca0949dee7b4f0c4137))
* **mpe_assert_abort:** cut at the separator, not the last marker ([4c7e869](https://git.datacontroller.io/dc/dc/commit/4c7e8693698ef7da6dd94e053cb6183a536004a3))
* **mpe_assert_abort:** name the separator and tolerate a carriage return ([7b09ade](https://git.datacontroller.io/dc/dc/commit/7b09ade97c4283fcff49a3e3d3b42a88085e575d))
* **mpe_assert_abort:** size the capture buffer for the whole message ([5d32de9](https://git.datacontroller.io/dc/dc/commit/5d32de9df97208e87e9baf7b60113ea155264510))
* **mpe_assert_abort:** strip the separator tail, not every trailing escape ([b29bc1e](https://git.datacontroller.io/dc/dc/commit/b29bc1e7ae6fa0c166e0649d96df1bc9aed9d54c))
* **mpe_execute:** assemble the abort text in a macro that can be tested ([e2e9103](https://git.datacontroller.io/dc/dc/commit/e2e91039cbf67cdc4306d4abb9d1db000c8ca1a3))
* **mpe_execute:** keep a child abort message intact when reporting it ([ebfd395](https://git.datacontroller.io/dc/dc/commit/ebfd39582fe6abe7249ce696cd79ff2a8962d53b))
* **mpe_execute:** keep the child message in the fallback, and test the doubled separator ([11587e2](https://git.datacontroller.io/dc/dc/commit/11587e2f2433fc68fc9e382b7fd3c3136a4edd34))
* **mpe_execute:** make the abort-text fallback reachable ([24b8cbe](https://git.datacontroller.io/dc/dc/commit/24b8cbe9dc3dc41a7fdd75d36de09dc7d7e4ca17))
* **mpe_execute:** mask a child abort message before it aborts the caller ([20dbf00](https://git.datacontroller.io/dc/dc/commit/20dbf00fb0b468549330cee049ff17bcdd99d5ea))
* **mpe_execute:** one symbol for the abort text, and no values in the call ([700b906](https://git.datacontroller.io/dc/dc/commit/700b906d69a20a73165335c8acbeb95f70dc1fea))
* **multi-dataset:** a sheet that failed to extract is not a clean finish ([3129648](https://git.datacontroller.io/dc/dc/commit/312964864ef5d908e181610245c46cd2e78b86cb))
* **multi-dataset:** show the file size and loading stage while the file loads ([45d6c02](https://git.datacontroller.io/dc/dc/commit/45d6c025c94946b7543f88ebaf92baae341d8a10)), closes [#121](https://git.datacontroller.io/dc/dc/issues/121)
* **report:** treat a blank PDF footer parameter as its default ([0f729d7](https://git.datacontroller.io/dc/dc/commit/0f729d7101ad14d556f7d356ffeb5366072e6be5))
* **test:** let the dirlist and writecheck results reach the caller on Viya ([ed678f0](https://git.datacontroller.io/dc/dc/commit/ed678f0b4616da0cef7f61b5e8b890cbdc26e912))
* **test:** run metanav.cy.ts in CI and open a metadata object ([02a8590](https://git.datacontroller.io/dc/dc/commit/02a8590310afce6c43a9218b67824cc6ac988710))
* **va-host:** bound the early replay even when the frame is not postable ([f6d5a04](https://git.datacontroller.io/dc/dc/commit/f6d5a0495e922ef3f3c14b0c291e4f8301a865ba))
* **va-host:** deliver in load mode, honour dataType, and survive odd prompt names ([7d4155d](https://git.datacontroller.io/dc/dc/commit/7d4155dc6ca0f3ec788a9e33719e9e29fe82d470))
* **va-host:** deliver the load-mount message once, and document report.json from the file ([90784b3](https://git.datacontroller.io/dc/dc/commit/90784b342f82d480fd67fdbcad5f65e8cd9c59e0))
* **va-host:** keep the deploy password off argv, and verify the app is served ([7c63a5c](https://git.datacontroller.io/dc/dc/commit/7c63a5c93f6efb64afa13e183fd2e8e91ff42679))
* **va-host:** post to the frame's own origin, and key the replay to the document ([9cec292](https://git.datacontroller.io/dc/dc/commit/9cec292681fbc8d331dc7e9ad063c350529de1a3))
* **viewer:** clamp over-tall PDF rows, always emit an otherwise-empty page ([10229d5](https://git.datacontroller.io/dc/dc/commit/10229d5efe3f1fa652cb4c168d40045bd3788771))
* **viewer:** defer the blob URL revoke, reject a selection inside skipped columns ([b5bb704](https://git.datacontroller.io/dc/dc/commit/b5bb704a4fb346c71c7159814b7e9bdc246e80a0))
* **viewer:** keep the PDF export working where hiddenColumns is not enabled ([f84d4ca](https://git.datacontroller.io/dc/dc/commit/f84d4ca7af55b577c644db28b0c375cc0ea4e9b5))
* **viewer:** review findings - CSV skipped column, PDF row cap before reading ([2c4be5e](https://git.datacontroller.io/dc/dc/commit/2c4be5e94b0f4590e96deac72bbaac36d5297784))
### Features
* **admin:** find and test a writeable location during deploy ([442c595](https://git.datacontroller.io/dc/dc/commit/442c595c2bb5983ba177d35b49d37c31a8a433d9))
* **deploy:** pick and validate the DC location from the deploy screens ([6eeab2e](https://git.datacontroller.io/dc/dc/commit/6eeab2ed16655c7e57ad4b8d3d678832fd225d14))
* **editor:** convert CASE rule values on entry ([c4eef49](https://git.datacontroller.io/dc/dc/commit/c4eef498b22af47258cd5cfac317e2020c8b7e31))
* **logs:** read the logs modal with the shared log parser ([aabc578](https://git.datacontroller.io/dc/dc/commit/aabc578d5ec787837450e4a12ade307b7cda08fc))
* **mocks:** add the control-table services the screens call ([a1615ee](https://git.datacontroller.io/dc/dc/commit/a1615ee8a711ccc4a4ad8a7b7638b3fd6a26b18e))
* **mocks:** build every JS stored program's response with webout() ([ce6bb5d](https://git.datacontroller.io/dc/dc/commit/ce6bb5d4cde50d47d2a5f7d2d3066ff086172603))
* **mocks:** emit the lineage, usernav and libinfo responses through webout() ([1f54fad](https://git.datacontroller.io/dc/dc/commit/1f54fad3fe7d0a42387f7e5e9fa661409f892a86))
* **mocks:** emit the metanav responses through webout() ([e1d877f](https://git.datacontroller.io/dc/dc/commit/e1d877f21d0882ee2220ce143583d2d6fb080cf8))
* **mocks:** emit the sas9 services through webout() ([7be3f84](https://git.datacontroller.io/dc/dc/commit/7be3f8407384babdf59979e91f706b5e8025579a)), closes [#461](https://git.datacontroller.io/dc/dc/issues/461)
* **mocks:** restore a load, and record the diffs it needs ([1a60c68](https://git.datacontroller.io/dc/dc/commit/1a60c689f4a1b7b3b374829a669638fc493a899f))
* **mocks:** use the server's webout() in the JS stored programs ([ccb67ed](https://git.datacontroller.io/dc/dc/commit/ccb67ed5a0981a9fb36f44214e364c08b3384e5c))
* **va-host:** emulate a VA report that embeds DC, as a deployable AppStream app ([2185db9](https://git.datacontroller.io/dc/dc/commit/2185db911d0333ef006cca7dc7a0f3d6aa7884a6))
* **viewer:** download a server-side PDF report ([67ca129](https://git.datacontroller.io/dc/dc/commit/67ca129c4db3eaead56a287811c578634ff59b19))
* **viewer:** export the grid as PDF from the context menu ([e58e735](https://git.datacontroller.io/dc/dc/commit/e58e7356103df350cf8ee1dd6e9eb59c8db57dd4))
### Reverts
* keep the helpers nested inside makedata_body ([4424171](https://git.datacontroller.io/dc/dc/commit/4424171b2f1cc89a7235c415557c72e0b9e7148a))
# [7.21.0](https://git.datacontroller.io/dc/dc/compare/v7.20.0...v7.21.0) (2026-10-29)
### Bug Fixes
* **client:** parse word-date values from VA parameters ([405b28d](https://git.datacontroller.io/dc/dc/commit/405b28dcccfe5994521bf63f0270d4877f544294))
* **client:** parse word-date values from VA parameters ([ce5b1aa](https://git.datacontroller.io/dc/dc/commit/ce5b1aa96175d0fd1f048fea6d127cbc743804f2))
* **makedata:** name the sasjs server group in the estate seed ([eeffa92](https://git.datacontroller.io/dc/dc/commit/eeffa92f9e973b323171bbefe2ef1cbc27e3aee7))
### Features
* **derived:** apply the core field scan in the MPE_DERIVED hook ([a4a0f9c](https://git.datacontroller.io/dc/dc/commit/a4a0f9cb2f7c0b6252405b71df64aae912d1b075))
# [7.20.0](https://git.datacontroller.io/dc/dc/compare/v7.19.0...v7.20.0) (2026-10-28)
### Bug Fixes
* **ci:** install doxygen for the SAS docs job ([f58925b](https://git.datacontroller.io/dc/dc/commit/f58925b29956f1f753f0a367fdcd0afe7f3f51a0))
* **ci:** keep foreign release assets, install jq first, pin the CLI ([27686d3](https://git.datacontroller.io/dc/dc/commit/27686d3aad5e34ace59ca1b735a17cca1a4025e1))
* **ci:** publish against the tag this job cuts, not releases/latest ([8888734](https://git.datacontroller.io/dc/dc/commit/888873421c3aeaf312d5ec45e3c805317c66976a))
* **ci:** publish docs after the assets, with the webdoc app password ([dd482c4](https://git.datacontroller.io/dc/dc/commit/dd482c4069202a0c6b9983129c19efc09bd160a7))
* **ci:** publish the code documentation with surfer 7 and app passwords ([25ffa71](https://git.datacontroller.io/dc/dc/commit/25ffa71adddba5e334f4a8c166f284d1be498fcd))
* **ci:** publish the typedoc to the webdoc site, not the app default ([c5808a1](https://git.datacontroller.io/dc/dc/commit/c5808a16eba0d29562fcc76f13ac4a33ab8da0b7))
* **ci:** rebuild and upload release assets on demand ([6486c80](https://git.datacontroller.io/dc/dc/commit/6486c80ed4f368bbf116c0ecdecc0e5e5f02075f))
* **ci:** use the lockfile and the pinned CLI in the docs job ([af1813f](https://git.datacontroller.io/dc/dc/commit/af1813f829861df41c89b512c824e8d3ebac7a4b))
* **ci:** verify the forge certificate on the release API calls ([cf7b21a](https://git.datacontroller.io/dc/dc/commit/cf7b21a8b61ec3bf7ebe21968ccf13ade264bc64))
* **cypress:** hold download out too, and retry once in CI ([c97e886](https://git.datacontroller.io/dc/dc/commit/c97e8866db9ee404b2118d24baf53fc7058debef)), closes [#395](https://git.datacontroller.io/dc/dc/issues/395) [#398](https://git.datacontroller.io/dc/dc/issues/398)
* **cypress:** register the deleteFolder task, restore the spec order, hold metanav out ([c803773](https://git.datacontroller.io/dc/dc/commit/c803773d3c36476d8263f01fc3b8344aa3503d44)), closes [#395](https://git.datacontroller.io/dc/dc/issues/395)
* **deploy:** do not offer the deploy wizard inside a report ([80e8911](https://git.datacontroller.io/dc/dc/commit/80e8911af8857fa2c417f028cd59b64a87233b9e))
* **deploy:** keep the app query when opening the standalone URL ([e51953c](https://git.datacontroller.io/dc/dc/commit/e51953ca8a6734e1649bad68cf4ad2057d677334))
* **deploy:** one definition of the standalone URL, and test the guards ([459ff2d](https://git.datacontroller.io/dc/dc/commit/459ff2da371375e996839d69deb40f681c356f1d)), closes [#TCS](https://git.datacontroller.io/dc/dc/issues/TCS)
* **deploy:** strip the embed parameters by name, and scope the timezone runs to the VA specs ([f7e7538](https://git.datacontroller.io/dc/dc/commit/f7e753863b61d8545bd0777d80267661b3c58670))
* **embed:** build quarter ends from the calendar, not minus 24 hours ([7e3045a](https://git.datacontroller.io/dc/dc/commit/7e3045a21b67053b76a81bfe836e31571ccbffbf))
* **embed:** defer the column check until metadata loads, report AND-ed pairs ([2623a3f](https://git.datacontroller.io/dc/dc/commit/2623a3fb4ba3bf19c00636b1717b53d5a2d54f3a))
* **embed:** log a parse-time mapping problem once ([0011f7e](https://git.datacontroller.io/dc/dc/commit/0011f7e933d85be8e33debe3a6ecb9000edaabe3))
* **embed:** make the va_parmap diagnostics say what they mean ([541a61b](https://git.datacontroller.io/dc/dc/commit/541a61ba08bc7256a911c50de7078859cc46c84e))
* **embed:** match a mapping on the parameter label only, and warn only when the values differ ([e9216b0](https://git.datacontroller.io/dc/dc/commit/e9216b08956d5599662259a2b05cc1524c458df5))
* **embed:** never build a clause from a value that did not convert, and say so in the banner ([bd3c220](https://git.datacontroller.io/dc/dc/commit/bd3c220af2678a2a3f8394fe3084ee03c23d0b15))
* **embed:** one parameter maps one column, and report typos on both sides ([f83dc35](https://git.datacontroller.io/dc/dc/commit/f83dc359e258f5d1cb9fc49f0eaab9748995d823))
* **embed:** record the parameter label only, so a name-keyed va_parmap is reported ([aa91daf](https://git.datacontroller.io/dc/dc/commit/aa91daf9f78c00cea486d0f885f8e634366bd1a0))
* **embed:** resolve transform values as local dates and give ranges whole days ([10157c1](https://git.datacontroller.io/dc/dc/commit/10157c11595685efdc206fe7b16a5cafa098f26d))
* **embed:** warn when a va_parmap entry is ignored ([411f6c3](https://git.datacontroller.io/dc/dc/commit/411f6c3354b7fbfec11fef3b174ae1f8066c92ba))
* **makedata:** pair the %else of the keepme block so it parses in open code ([c0ea810](https://git.datacontroller.io/dc/dc/commit/c0ea810766ebfda5ae5358ab14146fbcc07fcc8e))
### Features
* **embed:** map VA parameters to DC columns from the URL ([15a1f34](https://git.datacontroller.io/dc/dc/commit/15a1f34d5263503cac83b4fe4e52482777dc2f1f))
* **embed:** resolve va_parmap values through named date anchors ([88010c3](https://git.datacontroller.io/dc/dc/commit/88010c3980c2ea1c39bbb024ed32d63e495e61f0))
# [7.19.0](https://git.datacontroller.io/dc/dc/compare/v7.18.0...v7.19.0) (2026-10-28)
### Bug Fixes
* address the review findings on the harness and refresh changes ([36e4e88](https://git.datacontroller.io/dc/dc/commit/36e4e88eb134d3b48217d6dd185ea3c03f03fba5))
* **ci:** dispatch the demo deploy with a tag ref and the actions scope ([698dea6](https://git.datacontroller.io/dc/dc/commit/698dea6d6b7a8635313f11acfcc7a4f39a02bc99))
* **ci:** restore the gitleaks ignore entries for the mock licence pair ([0d58be9](https://git.datacontroller.io/dc/dc/commit/0d58be975d8eb2d7f1ab6a648f760151392c7973))
* **client:** open the SAS log page with noopener ([072a133](https://git.datacontroller.io/dc/dc/commit/072a1337020970b5b4ff8aa31719cc5ffec7701e))
* **client:** refuse a truncated table in the multi-table upload ([1fb4818](https://git.datacontroller.io/dc/dc/commit/1fb4818d215f538a0ac51772ab1f261aeb91f7e1))
* **cypress:** apply the licence key when the estate has none ([be8f445](https://git.datacontroller.io/dc/dc/commit/be8f445ae1051e132fb6eb5a69b45264255326b5)), closes [input#file-upload](https://git.datacontroller.io/input/issues/file-upload)
* **dc_execute:** use = comparisons, not IN, in the fail-fast guard ([fed43cb](https://git.datacontroller.io/dc/dc/commit/fed43cb07ef7f2afc685612a124dbc16d7618e21))
* **dc_getlibs:** exclude the DC libref from the dictionary scan ([f5aade5](https://git.datacontroller.io/dc/dc/commit/f5aade59760cc376b0f96eb4c892c8bca82dde14))
* **dc_refreshcatalog:** terminate the generated statements ([1e22476](https://git.datacontroller.io/dc/dc/commit/1e224763ba80500a112812a983801968e3751959))
* **derived:** a fileref with nothing written is a FAIL, not an error ([9b3cf50](https://git.datacontroller.io/dc/dc/commit/9b3cf50b9c3889786605f0f5d9032d84c50f7107))
* **derived:** address five review findings ([ccd468b](https://git.datacontroller.io/dc/dc/commit/ccd468b3358c1418ee6d8e198b088a0cf23b8c24))
* **derived:** carry the placement sequence in mpe_derived ([be50cb5](https://git.datacontroller.io/dc/dc/commit/be50cb5d082e788c6ae106bc1d02e73a8c7b9646))
* **derived:** close four gaps in the MPE_DERIVED hook ([9914790](https://git.datacontroller.io/dc/dc/commit/9914790777f92128c74ecce67e6c67ee48824b45))
* **derived:** close the RLS and search gaps on derived columns ([9ef813d](https://git.datacontroller.io/dc/dc/commit/9ef813d439b48e7fefd35c426e62b99e5943002a))
* **derived:** close the two gaps in the derived/RLS invariant ([422f766](https://git.datacontroller.io/dc/dc/commit/422f766cd43db4ce740e1890fd5df30790eaa7e3))
* **derived:** compare the outref as text, prove the file is empty ([7830b7b](https://git.datacontroller.io/dc/dc/commit/7830b7bcfa9806dde840ce8dd3e6a1887d0b38be))
* **derived:** do not drop a shadowed column from staging or downloads ([2d5de9a](https://git.datacontroller.io/dc/dc/commit/2d5de9a324a07a36edc0a4f74c8fad09fde07cbf))
* **derived:** do not duplicate a shadowed column in the grid payload ([6a1128a](https://git.datacontroller.io/dc/dc/commit/6a1128a46352f77c95dd02df9309f0984a1db2d1))
* **derived:** eight review findings ([561d7fc](https://git.datacontroller.io/dc/dc/commit/561d7fc3ed2a460835619962cf4dcb29d3dc87c7))
* **derived:** enforce the ACTIVE picker pair in the extracted check ([1863076](https://git.datacontroller.io/dc/dc/commit/1863076bc956b4ff94bf7b85b3dde0d6e3b54d7a)), closes [#366](https://git.datacontroller.io/dc/dc/issues/366)
* **derived:** enforce the documented limits, and handle derived filters in VIEW ([d3dec68](https://git.datacontroller.io/dc/dc/commit/d3dec682aafeb143fe983d6546337cb6490f5620))
* **derived:** filtering a format catalog no longer aborts the service ([469229a](https://git.datacontroller.io/dc/dc/commit/469229aecbaad304a835a5f229df111c37ff7c0d))
* **derived:** guard the bitemporal cut-off, coalesce the selectbox seed ([7fb981d](https://git.datacontroller.io/dc/dc/commit/7fb981daa91bce29c2d62c246185d01dcad08d6b))
* **derived:** guard the empty physical column list in the dep check ([5f5d868](https://git.datacontroller.io/dc/dc/commit/5f5d8681b9d78280a5200309917415e135c3ce5b))
* **derived:** hold a derived column read-only whatever the table's security ([5f443b0](https://git.datacontroller.io/dc/dc/commit/5f443b0b16a2abd3c11d96caa7a043911c29ef01))
* **derived:** ignore a re-added name when collecting inactive rows ([6b5bf3e](https://git.datacontroller.io/dc/dc/commit/6b5bf3e6be89b22e5a633c34b9aecbc8c7d48ee2))
* **derived:** ignore inactive rows and VIEW rules in the submit guards ([85c89ca](https://git.datacontroller.io/dc/dc/commit/85c89ca2a4e02112ba496ac36a222f33675544da))
* **derived:** keep targetds for plain datasets, test the empty file ([f8f6568](https://git.datacontroller.io/dc/dc/commit/f8f65681e1489f4ce94d9127d5e4c0c82798e960))
* **derived:** make the model enforce the required columns, and leave VAR_FMT optional ([7efbf31](https://git.datacontroller.io/dc/dc/commit/7efbf31ea5ac234bd8f924fdf89a95d101b3aff1))
* **derived:** only the delete marker is a reserved name ([af31c36](https://git.datacontroller.io/dc/dc/commit/af31c36b670ec63a2a4ba056bf6c4b28f6096e08))
* **derived:** open the statements file with a long record length ([ee31782](https://git.datacontroller.io/dc/dc/commit/ee31782fbff909b540162b1bdfca6f307208331b))
* **derived:** pair the filterds %else with %do/%end ([88a38db](https://git.datacontroller.io/dc/dc/commit/88a38db877dba9b98ccf50f0cc7242674654586b))
* **derived:** raise the VAR_ORDER ceiling to 10000 ([27ddcfb](https://git.datacontroller.io/dc/dc/commit/27ddcfb5759d6eb8f9880adc7c271a408e7741ad))
* **derived:** refuse dependencies on inactive rows, and keep the RLS scope guard coherent ([7799a30](https://git.datacontroller.io/dc/dc/commit/7799a300df82339683eea21e4a89ce7a6471c575))
* **derived:** reject a dependency on a row this submission deletes ([8806504](https://git.datacontroller.io/dc/dc/commit/88065049674c2cc0141eef22704b759f5b708b0f))
* **derived:** repair the validation rows for MPE_DERIVED ([9a047ad](https://git.datacontroller.io/dc/dc/commit/9a047ad42ec86df7d1eaf5058c0edde56016e88f))
* **derived:** review feedback - cover the empty response, assert the last row ([e0da9f1](https://git.datacontroller.io/dc/dc/commit/e0da9f1098db63185538398ca478696cc849da4b))
* **derived:** stop the hooks aborting on their own macro variables ([688550a](https://git.datacontroller.io/dc/dc/commit/688550a28d9794ac6b878926a0e1e2ef8f6e9335))
* **derived:** truncate the derived label on an even quote boundary ([1b5c4b3](https://git.datacontroller.io/dc/dc/commit/1b5c4b3ecd962a28e08fc5671b57dbf7ae0b8d9f))
* **derived:** verify on stripped values, and label with the variable name ([4d9ad38](https://git.datacontroller.io/dc/dc/commit/4d9ad38df1fa89b0d5a12214d703d9c1fe5295f2))
* **editor:** a blank truncation note is not a warning ([f182179](https://git.datacontroller.io/dc/dc/commit/f18217940365f27ae388fae087e6126931e1222c))
* **editor:** keep decimal values when an uploaded file is edited manually ([04ce186](https://git.datacontroller.io/dc/dc/commit/04ce1861e7821063b1be42c22f92f62aed509602))
* **getcolvals:** report an invalid value, not missing inputs ([4bcee70](https://git.datacontroller.io/dc/dc/commit/4bcee706ffaf1929bdd14bb2fb15111174ca3916))
* **getdata.test:** pin the derived filter to a libref, and spell the table as it is ([45dce7b](https://git.datacontroller.io/dc/dc/commit/45dce7b11b7a4399c1d0dc9c3e7a283b1cf59df1))
* **getdata:** clear the globals it declares before use ([359b570](https://git.datacontroller.io/dc/dc/commit/359b570b85aa2f66507e81ea2977243be4590f83))
* **getddl.test:** ask for a table, not a whole library ([059cdf1](https://git.datacontroller.io/dc/dc/commit/059cdf176a7ec556b45ea1b21d880c048eba14ca))
* **getrawdata:** declare mp_ds2md so the Markdown download can run ([661ee19](https://git.datacontroller.io/dc/dc/commit/661ee19cfa5f996bf01f2b11817039f38dbb9985))
* **lint:** clear the sasjs lint warnings on the files 7.19 touched ([355c46a](https://git.datacontroller.io/dc/dc/commit/355c46a7b64994d0146a88657dc5635593249138))
* **makedata:** read KEEPME from every input row, and keep it out of the Viya seed ([d626cb8](https://git.datacontroller.io/dc/dc/commit/d626cb8f2daf94fde7ff21d924b9a2ca4968d983))
* **mocks:** address the audit-pack review findings ([489819b](https://git.datacontroller.io/dc/dc/commit/489819b05111323ae1e34edda22a5446cdbc2d87))
* **mocks:** carry the zip flags into the central directory ([c4021a4](https://git.datacontroller.io/dc/dc/commit/c4021a42ffaed2af80c53aaa1455249190b339b8))
* **mocks:** clear the licence for published sites, not for the estate ([62f55db](https://git.datacontroller.io/dc/dc/commit/62f55db170185900063f7a431cd8f375ebabf930))
* **mocks:** copy the physical column list before deriving ([f411b7d](https://git.datacontroller.io/dc/dc/commit/f411b7d269381e3f7cab2d85b6d8f7d7d50ea4d1))
* **mocks:** derive before filtering in the viewer, and drop the columns ([60ec470](https://git.datacontroller.io/dc/dc/commit/60ec470404e5c279b58ac6a01768dfc83413b197))
* **mocks:** derive the retained key wherever rows are staged ([74060c0](https://git.datacontroller.io/dc/dc/commit/74060c0585fb8b217cb181ea747ad318f5238868))
* **mocks:** drop derived variables before staging ([73d25b1](https://git.datacontroller.io/dc/dc/commit/73d25b1220d574ef2547fbd9e6e3cf1ceda375ee))
* **mocks:** key rk_underlying tables on their business key ([347dbb5](https://git.datacontroller.io/dc/dc/commit/347dbb53b01c47bbd562de14d5ccbae7d96407c6))
* **mocks:** register MPE_DERIVED and seed its field rules ([ee3c878](https://git.datacontroller.io/dc/dc/commit/ee3c8787efef9cd716bdd2bf80f6cb6751461db9))
* **mocks:** scope the derived hook's graph to the submission ([6cad23f](https://git.datacontroller.io/dc/dc/commit/6cad23fce9ac0cf64c247c40f3b7eac875fd4cfb))
* **mocks:** search the physical columns only in the viewer ([293022d](https://git.datacontroller.io/dc/dc/commit/293022d46b27e096fd514cdda49f688612df2f5b))
* **mocks:** stop the mock advertising invalid licence keys ([fb36379](https://git.datacontroller.io/dc/dc/commit/fb36379520028baaf4bc3f08131bfc88deab3ac3))
* **mpe_assert_abort:** drop the unused line variable ([9c294c0](https://git.datacontroller.io/dc/dc/commit/9c294c0ab5ca8fe30a235553a298c1c68a61442f))
* **mpe_assert_abort:** take the abort's own MSG, tolerate a missing response ([0a52d20](https://git.datacontroller.io/dc/dc/commit/0a52d20c5fc27b4adde5fc8ed6f39a58c48f62ff))
* **mpe_execute:** fail fast when the child left no status ([a036386](https://git.datacontroller.io/dc/dc/commit/a03638667f1968c5909f496d954eeaa13f316763))
* **mpe_execute:** no apostrophe in the SAS 9 status note ([2204085](https://git.datacontroller.io/dc/dc/commit/220408538b26016bda45e48d619cdbc3e1cbcc59))
* **mpe_execute:** read an unknown state as unmeasured, not failed ([f2b1428](https://git.datacontroller.io/dc/dc/commit/f2b1428ec3099b2b6e3c6da6627530ef81fab201))
* name the startupservice launcher button "Show log" ([3445107](https://git.datacontroller.io/dc/dc/commit/3445107d9a91642b67e114bcfcd81c3ce47f3f0f))
* offer the launcher on the globvars failure modal too ([ad2da45](https://git.datacontroller.io/dc/dc/commit/ad2da453896a51f2cc7e49cebb6c84f7b47d7395))
* **refresh:** name each catalogue load uniquely in the audit trail ([46bacdd](https://git.datacontroller.io/dc/dc/commit/46bacdd6d9ea814bcd02a1ff8aa3f447baf02f64))
* **refresh:** one audit folder per refresh call ([56b6620](https://git.datacontroller.io/dc/dc/commit/56b6620ef402b288ce78a2a77662b651e310753e))
* **sites:** round-trip the committed licence before publishing ([4d462d3](https://git.datacontroller.io/dc/dc/commit/4d462d310f7df23e5d5229cb0702721b7ad41bbd))
* **test:** getdata test ([da30266](https://git.datacontroller.io/dc/dc/commit/da30266278c4fe27a72810cd5ed647cf3bd823af))
* **tests:** enable closeouts logging ([fdcd8fa](https://git.datacontroller.io/dc/dc/commit/fdcd8fa4b3e7a7a119c662974d70dc9eea80bdae))
* **tests:** read the work location from _SASJS_WORK ([033ffd3](https://git.datacontroller.io/dc/dc/commit/033ffd302502c7530fc44a314364f5cdb3be6ad2))
* **ui:** one error modal at a time ([81ccf75](https://git.datacontroller.io/dc/dc/commit/81ccf758e9dab0c7632ab50bf469e88b8c4c4420))
* **validatefilter:** build targetds without macro triggers in comments ([42355f1](https://git.datacontroller.io/dc/dc/commit/42355f17bc07bf4d44ba858984d83b9ed72cc0ba))
* **validatefilter:** drop the dead nobs assignment, keep targetds precedence ([337ea8e](https://git.datacontroller.io/dc/dc/commit/337ea8e5e021c6f2451fa3ad8396148744118fc9))
### Features
* **dc_getlibs:** dump the library list when debug is on ([582523d](https://git.datacontroller.io/dc/dc/commit/582523dc87a253b71824359224070ccb4746a386))
* **derived:** allow a variable to shadow a physical column ([12beeff](https://git.datacontroller.io/dc/dc/commit/12beeff69518376a042fbbc58332acc40d89d132))
* **derived:** declare VAR_DEPENDENCIES instead of VAR_ORDER ([813bce6](https://git.datacontroller.io/dc/dc/commit/813bce685691ad252043eeab31eb5460de81b756))
* **derived:** optional VAR_LABEL on MPE_DERIVED ([7c9ce9a](https://git.datacontroller.io/dc/dc/commit/7c9ce9a712137e9825a5f2ff52796d765423147b))
* **derived:** pickers, and an ACTIVE default, for MPE_DERIVED ([87e794f](https://git.datacontroller.io/dc/dc/commit/87e794f769fe420f214f43b141f0b41cf88eefee))
* **derived:** report the dependency cycle path ([cee1a95](https://git.datacontroller.io/dc/dc/commit/cee1a954ee97e17f297b7cf43ef0e5cfc6e393a6))
* **derived:** take any text in VAR_LABEL, but not a line break ([78e88a4](https://git.datacontroller.io/dc/dc/commit/78e88a41128b047c0276ccf647f128229152604c))
* **derived:** validate MPE_DERIVED at submit time ([0b010f2](https://git.datacontroller.io/dc/dc/commit/0b010f2c65ddada70d62ea83c706943c6c45756f))
* **derived:** validation rules and a VAR_TYPE dropdown for MPE_DERIVED ([aa6d50e](https://git.datacontroller.io/dc/dc/commit/aa6d50e1ea5fc2fe6be8aa9fa859b92d0a1061c5))
* **derived:** VARIABLE_ORDER, so a derived variable can build on another ([33d043e](https://git.datacontroller.io/dc/dc/commit/33d043ea92f007dca70c95d5363329d4447eeba9))
* **editor:** derived variables in the EDIT screen ([b0fd354](https://git.datacontroller.io/dc/dc/commit/b0fd354ecdbd7bf29196ed983ff3cce691affeed))
* **editor:** mark derived variables as computed in the EDIT grid ([6513d09](https://git.datacontroller.io/dc/dc/commit/6513d09f059de15c5304f99b3f8d898d46ea1be7))
* **editor:** serve a truncated selection with a warning instead of aborting ([34691dc](https://git.datacontroller.io/dc/dc/commit/34691dceecb63f952b23ad8acc5bc93753c6cfee))
* **editor:** show the derived column label in the header tooltip ([cfe6647](https://git.datacontroller.io/dc/dc/commit/cfe6647ca870c45a3d8abc2753ce7f070e3fdf4a))
* **mocks:** add getauditfile and the audit-folder artefacts ([752d944](https://git.datacontroller.io/dc/dc/commit/752d944c2091ef6d2624a15ab2e2e7be35608798))
* **mocks:** implement derived variables in the JS services ([b4fae88](https://git.datacontroller.io/dc/dc/commit/b4fae88ae13658607e4209449577cd42849d3a99))
* **mocks:** validate a submitted MPE_DERIVED row like the hook does ([5d183d1](https://git.datacontroller.io/dc/dc/commit/5d183d135085ea023f32e5b1d4992d997394a8fb))
* run startupservice in a new window from its error modal ([40f6133](https://git.datacontroller.io/dc/dc/commit/40f61331aad6bc8a52c551b924f0d1f1e6426cee))
* **sites:** mint a licence for the published sites ([7d9df71](https://git.datacontroller.io/dc/dc/commit/7d9df71fdb0ab265e745ed3b708038983e855a87))
### Reverts
* **derived:** no shadowing, and no self reference ([5af8e6f](https://git.datacontroller.io/dc/dc/commit/5af8e6fc359221586af74a685159dfd75a40ec45))
* **stagedata.test.4:** back to the MPE_LOADS evidence for now ([31aa859](https://git.datacontroller.io/dc/dc/commit/31aa8591538409fc1a3ef97d7474120094ce202d))
# [7.18.0](https://git.datacontroller.io/dc/dc/compare/v7.17.0...v7.18.0) (2026-10-19)
### Bug Fixes
* **deps:** bump @sasjs/core to 5.4.0 ([19fb032](https://git.datacontroller.io/dc/dc/commit/19fb032f0dbdd612651ca4ff3b55d1aefa4850d5))
* **deps:** pin @sasjs/utils and drop the unused jsrsasign ([05d2d1e](https://git.datacontroller.io/dc/dc/commit/05d2d1e08e2c3082f0dd5aa4629d91907c0401fc))
* **editor:** own the delete-record column client-side ([56025cc](https://git.datacontroller.io/dc/dc/commit/56025cc0524d34d3589992e9f1059c13fb5581ed))
* **editor:** surface the reserved-column refusal on every getdata path ([b26f557](https://git.datacontroller.io/dc/dc/commit/b26f5571b66b65166c1158817f2df3027ae45cdc))
* **filters:** do not turn a fractional temporal value into a literal ([f710d87](https://git.datacontroller.io/dc/dc/commit/f710d871fffc0fa451fd8cc4d037a26414725654))
* **filters:** fix the dynamic where clause deref and map its temporal values ([ece67d3](https://git.datacontroller.io/dc/dc/commit/ece67d37ba2c4e2037dea626b9730a692388f025))
* **filters:** keep the range field readable and cover a BETWEEN in the rewrite ([be79f3a](https://git.datacontroller.io/dc/dc/commit/be79f3a1ea5e4c4a157396d276f2c6654f615d39))
* **filters:** map an IN entry to its unformatted value before the literal ([8f98ebe](https://git.datacontroller.io/dc/dc/commit/8f98ebe326978b8da58d8ac895ec946e562b5de4))
* **filters:** stop the temporal rewrite corrupting a typed value, and bind the range dropdown to the unformatted value ([a5f61d4](https://git.datacontroller.io/dc/dc/commit/a5f61d4a73051237b671695f67122c237770c95a))
* **filters:** use the unformatted value in every IN branch ([cc01f54](https://git.datacontroller.io/dc/dc/commit/cc01f540c29d3d5f7dd86943b6eed82b332ce868))
* **makedata:** shorten the DC_MAXCELLS_WEBEDIT description to fit var_desc ([a51d9e5](https://git.datacontroller.io/dc/dc/commit/a51d9e5574383e5d1e1943c95261fbce63e8788d))
* **multi-dataset:** surface the reserved-column guard, use the name constant ([2b8a2fa](https://git.datacontroller.io/dc/dc/commit/2b8a2fa1039da3ab888af8c3ffae7e1b848b250a))
* **release:** emit the release body as raw text, not a JSON string ([a05554e](https://git.datacontroller.io/dc/dc/commit/a05554e67a8d44e32e2d9f5e4a959a9d0592be5e))
### Features
* **filters:** show dates as date literals in the filter query ([662d000](https://git.datacontroller.io/dc/dc/commit/662d00032c27634bbc2d82c392e7875a2e174eff))
# [7.17.0](https://git.datacontroller.io/dc/dc/compare/v7.16.0...v7.17.0) (2026-10-15)
### Bug Fixes
* **client:** do not publish an empty getcols fallback to the picker ([9f8fc6f](https://git.datacontroller.io/dc/dc/commit/9f8fc6f2eb83c78ea9cb220bb74059bab39e66ad))
* **client:** map the getcols fallback onto the picker vocabulary ([7cecf79](https://git.datacontroller.io/dc/dc/commit/7cecf795153138d6f2ad1f87f7bdd12b5d861341))
* **client:** pin the dependency entries this release changes ([b034fe5](https://git.datacontroller.io/dc/dc/commit/b034fe5335c41ec0289e459270839ee7caa8e126))
* **deploy:** apply the same identity-call fix to the automatic deploy ([5693b44](https://git.datacontroller.io/dc/dc/commit/5693b4488b0328022c69dd34f98662d660612578))
* **deploy:** disable the manual deploy actions without an admin group ([6ffbdba](https://git.datacontroller.io/dc/dc/commit/6ffbdbadf9804e0128694ed1c2bdb8f957743ad9))
* **deploy:** disable the manual deploy file inputs while admin groups load ([7094bfd](https://git.datacontroller.io/dc/dc/commit/7094bfdf7cdab4c70e5f1a7763a8a8aeb8cc61a7))
* **deploy:** guard the manual deploy form and Viya identity calls ([6522fcb](https://git.datacontroller.io/dc/dc/commit/6522fcb28b67b910b2784b93251f591bbc79d294))
* **deploy:** never bake an empty admin group into the deploy script ([b0e47fc](https://git.datacontroller.io/dc/dc/commit/b0e47fc4ed4e36878de6505e678667b14d19c5e6))
* **deps:** pin shelljs to clear the braces advisory in the sas audit ([6e50938](https://git.datacontroller.io/dc/dc/commit/6e5093858be106a1c3545a16119d246f69aac3da))
* **editor:** check the EDIT limits once, and skip switched-off options ([f08c4c2](https://git.datacontroller.io/dc/dc/commit/f08c4c231f019979a27fe71fe73798752c015710))
* **filters:** carry TYPE on the fallback list, and drop the temporal exclusion ([bcb8fde](https://git.datacontroller.io/dc/dc/commit/bcb8fde9868b8e1ded1590e204c34054f62d8250))
* **filters:** source the picker variables from public/getcols when the table is not returned ([3a6a216](https://git.datacontroller.io/dc/dc/commit/3a6a216b9d4dfc462e05031039c47002a9156130))
* **getcols:** use %global and %mp_getcols, and merge the base branch ([1385741](https://git.datacontroller.io/dc/dc/commit/1385741f2d303c1432f1f0ba637dd3cb39500a71))
* **getcols:** widen TYPE before assigning the client vocabulary ([bcad05e](https://git.datacontroller.io/dc/dc/commit/bcad05e03291fc3e2b72a4465629e1e330d70c8a))
* **getdata:** apply row level security to selectbox source values ([768d33c](https://git.datacontroller.io/dc/dc/commit/768d33c618a7ea453c3c19723f342a170ec576ea)), closes [#340](https://git.datacontroller.io/dc/dc/issues/340)
* **mocks:** read MPE_CONFIG correctly, and pin the limits to it ([05126ae](https://git.datacontroller.io/dc/dc/commit/05126ae73a1ab2461066eb701b7475674b96f1ec))
* repair the SAS suite for the row/cell limits and default the manual deploy admin group ([99ff732](https://git.datacontroller.io/dc/dc/commit/99ff732d67425b01a8a8f5e3e7c98b3e6d604323))
* **requests-modal:** widen the dialog so log lines need less scrolling ([3c10d91](https://git.datacontroller.io/dc/dc/commit/3c10d915a5134247aff0e987dd4d5c83c197e4c5))
* **sas:** keep format and label in the public getcols response ([9c32b1b](https://git.datacontroller.io/dc/dc/commit/9c32b1b1dae00e4b7bfd4acad94cbed831819d84))
* **sas:** keep the SAS C/N vocabulary in the public getcols response ([e90bcde](https://git.datacontroller.io/dc/dc/commit/e90bcde51218c9e5f562f7aaa65506761b21d6bd))
* **sas:** run the row/cell limit migration as a Viya job ([90af6b8](https://git.datacontroller.io/dc/dc/commit/90af6b851542a97f79172b655070cda559386a5c))
* **startup:** stack the SASjs tag detail so the dump is readable ([1f4c4a4](https://git.datacontroller.io/dc/dc/commit/1f4c4a4d8297568f46bcc16ea208673fedad345b))
### Features
* **debug:** keep the debug toggle state across a page refresh ([3ce006d](https://git.datacontroller.io/dc/dc/commit/3ce006d6b469b4ece2bc769f0a32a1792eb1e774))
* **editor:** raise the row limits and add a cell limit to the EDIT screen ([3de6655](https://git.datacontroller.io/dc/dc/commit/3de6655f26b7e895f60266e9931b46cfc0053975))
* **mocks:** read the DC_MAX* options from MPE_CONFIG, and mock the DDL export ([edac9a9](https://git.datacontroller.io/dc/dc/commit/edac9a987aad3e4217641eed43f697f61c4bf739))
* **startup:** call out a missing SASjs tag on the boot screen ([b24eb73](https://git.datacontroller.io/dc/dc/commit/b24eb731ce089acf496bed1e11a5e34208c1bd11))
* **startup:** dump the SASjs tag contents in the boot screen checks ([75c1dcc](https://git.datacontroller.io/dc/dc/commit/75c1dccfd23557ab321cd7abd1508f20d817c7b8))
* **system:** export the DC library DDL from the system screen ([05f3f49](https://git.datacontroller.io/dc/dc/commit/05f3f49e0c4d7005fa1f93852d73f2368395ab28)), closes [#179](https://git.datacontroller.io/dc/dc/issues/179)
# [7.16.0](https://git.datacontroller.io/dc/dc/compare/v7.15.0...v7.16.0) (2026-09-28)
### Bug Fixes
* **ci:** correct server-ci webSourcePath and build frontend before mock deploy ([a4ad4cb](https://git.datacontroller.io/dc/dc/commit/a4ad4cba2861fe76f7ee5ea72ccf8c6e61c888a5))
* **client:** parse iOS browsers in parseUserAgent ([eec3438](https://git.datacontroller.io/dc/dc/commit/eec3438de9735e0feb74c95d0b28128af6677477))
* **diagnostics:** give the startup service a body for browser_info ([5538e0c](https://git.datacontroller.io/dc/dc/commit/5538e0c5752553a7c61903c62e4fdc82b6206826))
* **diagnostics:** treat _debug=128 as debug on ([8de9978](https://git.datacontroller.io/dc/dc/commit/8de99782134e37c5e9c26c3117b083f49fa65189))
* **loader:** abort when a post edit hook routes to an unregistered table ([171974b](https://git.datacontroller.io/dc/dc/commit/171974bd412be3efafd792e6c61c23f2fb442ace))
* **mocks:** keep the mock hook reader inside the Drive ([7fd8ae4](https://git.datacontroller.io/dc/dc/commit/7fd8ae462993f0a1a8a034b5938d309d63920043))
* **sas:** drop the duplicated abort in the target re-registration check ([48d45ac](https://git.datacontroller.io/dc/dc/commit/48d45ac7f8c32e39ff2d0e1ceac50252a49ebadd))
* **sidebar:** drop the query string from the sub-page label ([d2dd46b](https://git.datacontroller.io/dc/dc/commit/d2dd46bfdfddd1695d1b3915d20ce79330cf275b))
### Features
* **client:** report the browser and both versions in browser_info ([a770f36](https://git.datacontroller.io/dc/dc/commit/a770f3695ceac441f48527f606843602c39fcc13))
* **client:** send session_info with every service request ([50282f2](https://git.datacontroller.io/dc/dc/commit/50282f2ddb1250e048c55c04b395b428167142f7))
* **filters:** make the applied-filter panel expandable ([fdaa249](https://git.datacontroller.io/dc/dc/commit/fdaa249f447b3a2c87da4b2aa1e5978c09f53ff8))
* **mocks:** emulate the %mpeinit diagnostics dump ([200ee89](https://git.datacontroller.io/dc/dc/commit/200ee8931d3952624434567b5b95c9e7e53c6508))
* **mocks:** run pre/post edit hook programs in the JS mock services ([b5f2283](https://git.datacontroller.io/dc/dc/commit/b5f228351bca18cbc8a3032e60846d05192668f6))
* **mpeinit:** dump session_info to the log when debug is on ([fbd77a5](https://git.datacontroller.io/dc/dc/commit/fbd77a533417b1c8549a339b94785f7af0a30ca8))
* **release:** publish SHA256SUMS and verify instructions with each release ([f1ae501](https://git.datacontroller.io/dc/dc/commit/f1ae501d49b510a1870f3682bb7d83de7446e061))
# [7.15.0](https://git.datacontroller.io/dc/dc/compare/v7.14.2...v7.15.0) (2026-09-23)
### Bug Fixes
* **mocks:** keep special missings in the DIFF, and add the clip recording spec ([d124ce3](https://git.datacontroller.io/dc/dc/commit/d124ce3b36f8f5153e8d7a576d4bec3189524ed5))
* **mocks:** list a column's own special missing in its dropdown ([b8f16a6](https://git.datacontroller.io/dc/dc/commit/b8f16a63827310f68629f2e016a4fb876593c37c))
* **mocks:** make the approval path work, and record the approval scene ([12847cf](https://git.datacontroller.io/dc/dc/commit/12847cf07165d5aefa61592815cab0a37245e31f))
* **validator:** a range rule ignores a missing value ([70dae4b](https://git.datacontroller.io/dc/dc/commit/70dae4b7013ac825eb77d3dc2b622980c0e4af05))
* **validator:** keep the regular missing in a numeric dropdown source too ([a639bca](https://git.datacontroller.io/dc/dc/commit/a639bca70264e8a6a81795892b9ed63ba58d5f73))
* **validator:** primary keys are NOT NULL, and a strict dropdown can match a special missing ([586a41a](https://git.datacontroller.io/dc/dc/commit/586a41ad49670ee795284a83cf8535711ce14d2c))
* **validator:** reject a special missing on a NOT NULL column ([6d85bce](https://git.datacontroller.io/dc/dc/commit/6d85bce2a0c528e8eab9ee4abbade8c682ec3a1b))
### Features
* **validator:** compare MINVAL and MAXVAL in SAS's own order ([3467322](https://git.datacontroller.io/dc/dc/commit/3467322e99b0c3f6fda5654d35b507fdb2cb1c22))
## [7.14.2](https://git.datacontroller.io/dc/dc/compare/v7.14.1...v7.14.2) (2026-09-22)
### Bug Fixes
* **release:** put frontend files at the root of frontend.zip ([7771a24](https://git.datacontroller.io/dc/dc/commit/7771a24b9ce55751253686885e4848a292817a38)), closes [#147](https://git.datacontroller.io/dc/dc/issues/147)
* **security:** accept the format-catalog form when validating a libds ([0fa5da8](https://git.datacontroller.io/dc/dc/commit/0fa5da8abfc303ce3b18beb4c01484dcce508439))
* **security:** escape col-info dropdown and origin-check VA replay ([c9eed6d](https://git.datacontroller.io/dc/dc/commit/c9eed6dae717414b874ef6f3d60312ea0bb5a0da))
* validate request inputs and add admin gates to public services ([be86000](https://git.datacontroller.io/dc/dc/commit/be86000fe0f0858edcbecf71d426a2ff81257247))
## [7.14.1](https://git.datacontroller.io/dc/dc/compare/v7.14.0...v7.14.1) (2026-09-17)
### Bug Fixes
* **admin:** return refreshcatalog output as webout JSON ([1dccf61](https://git.datacontroller.io/dc/dc/commit/1dccf61f6290e50ebc365964fab864cb93f8d1b5))
* **deps:** bump @sasjs/cli to 4.20.3 ([d301fc2](https://git.datacontroller.io/dc/dc/commit/d301fc2b4dc86b7eb3027b8c8965dd7d1079363e))
* **deps:** bump @sasjs/core to 5.2.7 ([09f31ce](https://git.datacontroller.io/dc/dc/commit/09f31ce6da1e6a286c4e05ac55fb1d0c801d62f5))
* **deps:** bump @sasjs/core to 5.2.8 ([c73817f](https://git.datacontroller.io/dc/dc/commit/c73817fb6a8c19d970a19b98d5ef79c05243cc8d))
* **editors:** declare the mp_validatecol dependency in stagedata and loadfile ([a556be4](https://git.datacontroller.io/dc/dc/commit/a556be4c577020c8e3147de4d3e71ab84fb0d174))
* **hooks:** make the pre-commit size check actually run ([5383803](https://git.datacontroller.io/dc/dc/commit/53838036e635cd6f33dccc4534d6f258d3b3da5d))
* **hooks:** require the repo-pinned gitleaks, drop the fallbacks ([fbcde41](https://git.datacontroller.io/dc/dc/commit/fbcde4132147240fed21a88d44f62b68f58cf67c))
* **security:** escape cell values in status renderers to prevent DOM XSS ([cfd8f06](https://git.datacontroller.io/dc/dc/commit/cfd8f064352940cd57cf19c86504036f0a7edd8d))
* **security:** validate libds in stagedata and loadfile ([2d31e5a](https://git.datacontroller.io/dc/dc/commit/2d31e5a8b957d30057eb749c2d7f246170af8c8f))
* **viewdata:** return the full row cap from a table search ([199b57d](https://git.datacontroller.io/dc/dc/commit/199b57d9f9fb4b00a229d23e25e22e5dee471d2a))
# [7.14.0](https://git.datacontroller.io/dc/dc/compare/v7.13.0...v7.14.0) (2026-09-04)
### Features
* **system:** show session timezone on system information screen ([966dc07](https://git.datacontroller.io/dc/dc/commit/966dc072e0cb8bb44bd93a7682c3d7335428d688))
# [7.13.0](https://git.datacontroller.io/dc/dc/compare/v7.12.0...v7.13.0) (2026-09-03)
### Bug Fixes
* dynamic js mocks ([3b9344c](https://git.datacontroller.io/dc/dc/commit/3b9344cb602b609774737dcbb6df6756a2a08edb))
* adding formula types ([c07a01a](https://git.datacontroller.io/dc/dc/commit/c07a01a38da6a6e4edaf014ef2fd4d2691b29698))
* address hermes review feedback (formula quoting, cell revert, addRow guard) ([36963aa](https://git.datacontroller.io/dc/dc/commit/36963aa74627b861aee9ec91428a8dbee0946dfc))
* agent skills and nextviya deploys ([f7db871](https://git.datacontroller.io/dc/dc/commit/f7db8719f5fe860605f4e6da81bad925d7412599))
* bump core for mp_validate fix ([5a44b28](https://git.datacontroller.io/dc/dc/commit/5a44b2804feddbd616ece1d721865e02bfbedda8))
* CAS support for REPLACE type plus docs ([ea05f07](https://git.datacontroller.io/dc/dc/commit/ea05f0718047a0ac54cffc58938b6d06f5a112e2))
* clarify debug comment is permanent, extend ComputeContextDetails with Viya response fields ([72484ae](https://git.datacontroller.io/dc/dc/commit/72484ae8441715813983faa221be348cf421b735))
* core major bump plus autofix of viya context on deploy ([6527c10](https://git.datacontroller.io/dc/dc/commit/6527c10f262249187c27723d983edd32c3848d72))
* **core:** bump to v5 (breaking change) ([59e9e96](https://git.datacontroller.io/dc/dc/commit/59e9e96f5a6a6c9c0560cbb8387982611a0c8ca4))
* **cypress:** correct mock data, test expectations, and abort modal ([13c10be](https://git.datacontroller.io/dc/dc/commit/13c10be30faae736d27b584e0507eaab702d2d32))
* **cypress:** make licensing combined-key tests order-independent ([4db8d62](https://git.datacontroller.io/dc/dc/commit/4db8d62dca9257764438b00ef034103a0703111a))
* **deploy:** Viya deploy checks, startup diagnostics, and chunked deploy script ([917925f](https://git.datacontroller.io/dc/dc/commit/917925f55393aca919c2a7c7cd7dbecadbc24ace)), closes [#303](https://git.datacontroller.io/dc/dc/issues/303) [#200](https://git.datacontroller.io/dc/dc/issues/200) [#125](https://git.datacontroller.io/dc/dc/issues/125)
* **deps:** align @angular/* packages to the same lockstep version ([24e6297](https://git.datacontroller.io/dc/dc/commit/24e62971879e61eef0fc3ea9f07796b92231dbe3))
* **deps:** override nanoid to 3.3.18 in sas/ ([d57ae03](https://git.datacontroller.io/dc/dc/commit/d57ae03fc44770f41de5dbe0389d33cd11fc0971))
* **deps:** pin @handsontable/angular-wrapper to 18.0.0 ([2d7df66](https://git.datacontroller.io/dc/dc/commit/2d7df66fc28ffe07e859920c052b9818ed58647f))
* **deps:** pin babel-loader to resolve an unresolvable peer conflict ([3bf3bf0](https://git.datacontroller.io/dc/dc/commit/3bf3bf0dde943246380528d5af7c46ad1f518c79))
* **deps:** regenerate lockfile with Node 24 and pin handsontable to 18.0.0 ([7da29bd](https://git.datacontroller.io/dc/dc/commit/7da29bdb1180a9f50055380c1df3649283e9cdf6))
* **deps:** regenerate lockfile with strict peer-dep resolution to fix npm ci in CI ([efd2e18](https://git.datacontroller.io/dc/dc/commit/efd2e18c7b09f80655528a2f6985034f5a41dcf3))
* **deps:** regenerate package-lock.json to resolve npm ci sync errors ([a501903](https://git.datacontroller.io/dc/dc/commit/a501903e6d4276f1b4eba8f8ae0feccafcfbcf86))
* **deps:** resolve npm audit findings and align @angular/* to the same lockstep version ([b9e4b27](https://git.datacontroller.io/dc/dc/commit/b9e4b2733fa01e2b8060dab54277000a2c6252b8))
* **deps:** resolve npm audit findings via direct lockfile patch, not regeneration ([4d1bfa6](https://git.datacontroller.io/dc/dc/commit/4d1bfa6343342a167bdc315204866a18bcbca8ed))
* **deps:** resolve npm audit vulnerabilities in production dependencies ([257f69c](https://git.datacontroller.io/dc/dc/commit/257f69ccc6dd070daf8c73f597a08d01e8ea6888))
* **deps:** scope the brace-expansion override to its actual vulnerable chain ([f9d061c](https://git.datacontroller.io/dc/dc/commit/f9d061c48901d9ef131303940f0c2e2948b12fdf))
* dynamic getdynamiccolvals.js ([d59ef44](https://git.datacontroller.io/dc/dc/commit/d59ef442266bd4823f5cad3f7f4985e3df9afb48))
* **editor:** clear sort before reading cells to preserve on cancel ([4137ebd](https://git.datacontroller.io/dc/dc/commit/4137ebdf6b6563eeac4f805708c3b4ffcd099fb1))
* **editor:** escape formula-looking values in uploaded Excel data ([e69df3d](https://git.datacontroller.io/dc/dc/commit/e69df3deb22d80b1371b63a5ae1c6a44538b00bc))
* **editor:** fix formula $-substitution corruption and sorted-grid row/formula desync ([e139c37](https://git.datacontroller.io/dc/dc/commit/e139c37fbfe46123cce2cbfcf7e919bc68e19a2f))
* **editor:** let the column info dropdown's text be selected and copied ([1e516f4](https://git.datacontroller.io/dc/dc/commit/1e516f4012947c3f42dec1f3a46fa364c39bfe3e))
* **editor:** re-mark a reverted cell as auto-escaped so "Apply as formula" works again ([12091c4](https://git.datacontroller.io/dc/dc/commit/12091c4044b0982c36b7d6011b51625bfb6b8cb5))
* **editor:** resolve a primary key's live formula to its computed value, not the raw formula text ([d72e19a](https://git.datacontroller.io/dc/dc/commit/d72e19a3086ef578a1304fe032e1088c6ef60cf7))
* **editor:** resolve computed value for a live formula in numeric columns too ([2cc8489](https://git.datacontroller.io/dc/dc/commit/2cc8489892638aaf97fd5999ebc6086ffd674a76))
* **editor:** restore multi-column sort with one call, not a loop ([8fafeb7](https://git.datacontroller.io/dc/dc/commit/8fafeb7923bc7dae178c4fed0898195d5b5825a6))
* **editor:** size table-header buttons and title to content, not fixed grid thirds ([d0a7561](https://git.datacontroller.io/dc/dc/commit/d0a7561f1aeb67b214e6a473cc6235509daa80bb))
* **editor:** stop dc.row_status from being submitted to the backend ([dc0f6a7](https://git.datacontroller.io/dc/dc/commit/dc0f6a7baa128e0b75d7341ac7c03dcd4b1ffee5))
* **editor:** stop turning missing columns into undefined, and fix column lookup for sparse rows ([a104f78](https://git.datacontroller.io/dc/dc/commit/a104f78645bc7777fee672956d3f028ff52ed9ba))
* **editor:** submit a live formula's computed value, not its raw text ([b7d3106](https://git.datacontroller.io/dc/dc/commit/b7d31066ae5272856ea442d0f76d2f0752015dbb))
* ensure cleanup of casuser temp table in error condition ([c4123e5](https://git.datacontroller.io/dc/dc/commit/c4123e5c965ba3f78ec8cf32f915f263d218ca56))
* ensuring formats arrive from backend in getstagetable.sas ([daacb49](https://git.datacontroller.io/dc/dc/commit/daacb49c8a2c6a266dd842a8b7ccdebaa992a1d7))
* escape regex metacharacters in context name before prxchange ([9023eb2](https://git.datacontroller.io/dc/dc/commit/9023eb2a393ee8441bab5b9c5ff4f53b500e0e91))
* failing test ([f213d24](https://git.datacontroller.io/dc/dc/commit/f213d24f096d814d286bfdac5a28748e97e7293f))
* fixed npm vuln ([26b55b1](https://git.datacontroller.io/dc/dc/commit/26b55b1bde5e28afde2d4f9889cb452985410c1d))
* **formulas:** avoid EDIT_STATUS colliding with a real column of that name ([acb97f4](https://git.datacontroller.io/dc/dc/commit/acb97f4bfb1c7d6a0b38dd33207170f6ec2bbd1d))
* hardening following PR review feedback ([1f4aa6f](https://git.datacontroller.io/dc/dc/commit/1f4aa6fceafc4b51ecc5a19f4789cf5bd018a5d3))
* history mock ([0082828](https://git.datacontroller.io/dc/dc/commit/00828285435a412e3e5dccb045b053c1096e8876))
* **licensing:** bridge async proceed() through cy.then and replace Response.arrayBuffer with manual stream reader ([8e51fde](https://git.datacontroller.io/dc/dc/commit/8e51fde524e799652fbd1f0b0763d667cb3a4845))
* **mocks:** add fixture staged data and MPE_SUBMIT row for stage.cy.ts ([6e2548d](https://git.datacontroller.io/dc/dc/commit/6e2548d78c613d684fc8d874ba11b30fc9b4e32e))
* **mocks:** add LIBRARYNAME to startupservice, fix viewbox column order ([c0af86e](https://git.datacontroller.io/dc/dc/commit/c0af86ef297334ee531e979754d4d289ed3900ea))
* **mocks:** limit stage fixture to 1 row, fix column order ([f25ecd8](https://git.datacontroller.io/dc/dc/commit/f25ecd88283433770f56167774617f0e2089c6ef))
* **mocks:** look up PK_FIELDS from MPE_TABLES in viewdata, fallback for MPE_AUDIT ([94d2a79](https://git.datacontroller.io/dc/dc/commit/94d2a7970e49845b235c71523418e4a5d2a70396))
* **mocks:** remove MPE_VALIDATIONS and MPE_ALERTS from DC996664 libref ([4f7b845](https://git.datacontroller.io/dc/dc/commit/4f7b845390d50db50114afa7fa851ddf3e853ed7))
* **mocks:** remove shadowed saveTableData in rejection.js ([fed0396](https://git.datacontroller.io/dc/dc/commit/fed039673777659594feb9a92a5d9c2dca2cc544))
* **mocks:** remove SOME_SHORTNUM HARDREGEX from MPE_VALIDATIONS ([34bd660](https://git.datacontroller.io/dc/dc/commit/34bd6607c547c3d40c1129395a28e4cbf0fa1475))
* **mocks:** rename saveTableData in postdata.js to avoid shadowing ([e4b04b1](https://git.datacontroller.io/dc/dc/commit/e4b04b1d447640542c122dce93d65219719cb094))
* **mocks:** SAS datetime format in getchangeinfo, login in stage.cy.ts ([c180934](https://git.datacontroller.io/dc/dc/commit/c1809341672ad51baa603f104176fdc302e4c0c1))
* more mock improvements ([a80927c](https://git.datacontroller.io/dc/dc/commit/a80927cbb6c8a5ce26a1d251dc10cae4bae1eedd))
* mp_execute dep ([023c29f](https://git.datacontroller.io/dc/dc/commit/023c29f00f74f964825e0ee01768baf44aead332))
* pin fast-uri to 3.1.7 to close high-severity SSRF/host-confusion advisories ([f4325b2](https://git.datacontroller.io/dc/dc/commit/f4325b205699bf02158c232ffd8c279489778512)), closes [hi#severity](https://git.datacontroller.io/hi/issues/severity)
* spy for startup ([69b4155](https://git.datacontroller.io/dc/dc/commit/69b41559f7aa66a537b685e26e00c44f1f2568c4))
* **stage:** submit data via editor flow, use dynamic date assertion ([490d013](https://git.datacontroller.io/dc/dc/commit/490d0137b921c150b431083b847d471aa379bc42))
* **startup:** show the real startupservice response text on a malformed reply ([9a1b7d0](https://git.datacontroller.io/dc/dc/commit/9a1b7d0f52eebe6bea229dedbcb8f5c19bc96141))
* tests and realistic getcolvals.js ([5d03164](https://git.datacontroller.io/dc/dc/commit/5d03164b6a3245b47f1278fe962c537f72a8f473))
* **tests:** new libref ([c0bc87d](https://git.datacontroller.io/dc/dc/commit/c0bc87d331b081d787b4ed3d04c639c139d0fa1b))
* unnecessary getsubmits call removed ([cc2ff87](https://git.datacontroller.io/dc/dc/commit/cc2ff873fae58f41332dcbe725a49f0815b219b3))
* using utility macro for webout ([3595bb9](https://git.datacontroller.io/dc/dc/commit/3595bb94993aa054235cce3f41b9d83711907c46))
### Features
* **editor:** color row-header status cells and switch modified symbol to ± ([e15f2c2](https://git.datacontroller.io/dc/dc/commit/e15f2c2a3488f43ed4e5814fdc22527961cc5c22))
* **editor:** generalize cell revert to any overwritten value, not just formulas ([07d586d](https://git.datacontroller.io/dc/dc/commit/07d586da5235a2d94b2b689c774c40851b9a57e2))
* **editor:** scope live formulas to character columns, not just HARDFORMULA/SOFTFORMULA ones ([92d0d91](https://git.datacontroller.io/dc/dc/commit/92d0d91440ff6d58d10f1af291386a1813cb07d4))
* **editor:** translate column names to cell references on formula paste ([1031ea7](https://git.datacontroller.io/dc/dc/commit/1031ea7ed7b1a74ecc77bf155f6d329feeaf4b13))
* **formulas:** flag formula-overwritten cells with revert, harden dc.row_status against SAS name collisions ([a8237b2](https://git.datacontroller.io/dc/dc/commit/a8237b2881b85854bb52c85ca2f17e3368f91f4a))
* **formulas:** live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix ([4a8c39b](https://git.datacontroller.io/dc/dc/commit/4a8c39b4c0d6e0edb3493ac3d26d86788ebfdf90))
* **formulas:** wire HyperFormula into HARDFORMULA/SOFTFORMULA rules ([cbea04c](https://git.datacontroller.io/dc/dc/commit/cbea04c8e129d7d28a626215aae3b67735a036d5))
* functional JS mocks ([ee52bb8](https://git.datacontroller.io/dc/dc/commit/ee52bb8967e5bcdcfd17500678295e6bffbe2cf0))
* **licensing:** support combined-key paste, object-format features, and a live key preview ([20ba633](https://git.datacontroller.io/dc/dc/commit/20ba63308717dbde929dbe5974b76f160343de29))
* **licensing:** warn and block applying a licence key generated for the wrong protocol ([41680e2](https://git.datacontroller.io/dc/dc/commit/41680e2eccad82584bf9fd71a1efd1fd2d8f1a39))
* **stage:** add Formatted/Unformatted toggle to the staging page ([74c38e1](https://git.datacontroller.io/dc/dc/commit/74c38e16416dcd8be20faa66d9f282a6438522b5))
* updated configurator for Viya deploy ([df2027d](https://git.datacontroller.io/dc/dc/commit/df2027dab6ae51fb9cabf21e35d1fa5d6e1f7957))
* **viewboxes:** add drag-to-resize on all four edges and corners ([e5a5bf2](https://git.datacontroller.io/dc/dc/commit/e5a5bf2144d7f35c62368924c7e59f55eeb246ad))
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
+9 -9
View File
@@ -6,9 +6,9 @@ This file is the shared glossary and orientation for the repo. When your output
## Repository layout
- `client/` - the Angular frontend (TypeScript). Uses [Handsontable](.agents/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agents/skills/hyperformula/SKILL.md) for Excel-formula support. Lint with `npm run lint:check` from `client/`.
- `client/` - the Angular frontend (TypeScript). Uses [Handsontable](.agent/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agent/skills/hyperformula/SKILL.md) for Excel-formula support. Lint with `npm run lint:check` from `client/`.
- `sas/` - the SAS backend. Services, macros, hooks and tests live under `sas/sasjs/**`; `sas/sasjsbuild/` is generated output (never hand-edit). Lint `.sas` files with `sasjs lint` from `sas/`.
- `.agents/docs/` - technical deep-dives (see below). `.agents/skills/` - task skills (handsontable, hyperformula, dc-sas for Data Controller SAS development, dc-estate-testing for deploying to and testing on the shared estate, plus npx-installed sasjs/ sas skills).
- `.agent/docs/` - technical deep-dives (see below). `.agent/skills/` - task skills (handsontable, hyperformula, sas).
## Roles
@@ -32,10 +32,10 @@ Admins are listed in `&mpeadmins`; admin membership bypasses Row Level Security.
## Load types
Set per table in `MPE_TABLES.LOADTYPE`. Determines the loader and how history is kept (see [.agents/docs/bitemporal-dataloader.md](.agents/docs/bitemporal-dataloader.md)):
Set per table in `MPE_TABLES.LOADTYPE`. Determines the loader and how history is kept (see [.agent/docs/bitemporal-dataloader.md](.agent/docs/bitemporal-dataloader.md)):
- **UPDATE** - no history; changed records deleted and re-appended (via `%bitemporal_dataloader`).
- **REPLACE** - no history; whole table wiped and reloaded. Implemented inline in `%mpe_targetloader`, deliberately *not* via `%bitemporal_dataloader` (see [.agents/docs/replace-load-type.md](.agents/docs/replace-load-type.md)).
- **REPLACE** - no history; whole table wiped and reloaded. Implemented inline in `%mpe_targetloader`, deliberately *not* via `%bitemporal_dataloader` (see [.agent/docs/replace-load-type.md](.agent/docs/replace-load-type.md)).
- **TXTEMPORAL** - SCD2-style history on technical (transaction) time.
- **BITEMPORAL** - full two-dimensional history (business time + technical time).
- **FORMAT_CAT** - format-catalog load (via `%mp_loadformat`).
@@ -64,11 +64,11 @@ Selectbox seed values for these tables are defined in `sas/sasjs/macros/mpe_make
- **HARDREGEX** - submission-blocking regex; failing cells are painted red and cannot be submitted.
- **SOFTREGEX** - display-only warning regex; failing cells are painted yellow but submission is allowed.
Regex rule values are authored in SAS PRX form `/pattern/flags` and limited to 128 chars. See [.agents/docs/regex-validations.md](.agents/docs/regex-validations.md).
Regex rule values are authored in SAS PRX form `/pattern/flags` and limited to 128 chars. See [.agent/docs/regex-validations.md](.agent/docs/regex-validations.md).
## Security
- **Row Level Security (RLS)** - server-side `WHERE`-clause generation via `%mpe_filtermaster` / `%mp_filtergenerate`; no data leaves SAS without passing the filter. Works on any engine because it's expressed as a standard SAS `WHERE`. On write, the *inverse* filter is applied to reject out-of-scope rows. See [.agents/docs/row-level-security.md](.agents/docs/row-level-security.md).
- **Row Level Security (RLS)** - server-side `WHERE`-clause generation via `%mpe_filtermaster` / `%mp_filtergenerate`; no data leaves SAS without passing the filter. Works on any engine because it's expressed as a standard SAS `WHERE`. On write, the *inverse* filter is applied to reject out-of-scope rows. See [.agent/docs/row-level-security.md](.agent/docs/row-level-security.md).
- **Column Level Security (CLS)** - restricts visibility/editability at column level.
## Key macros / services
@@ -79,7 +79,7 @@ Regex rule values are authored in SAS PRX form `/pattern/flags` and limited to 1
## Testing
Backend tests run with the sasjs CLI from `sas/`. `npm run 4gl` compiles+deploys+seeds then `sasjs test -t 4gl` runs tests. Assertions use `%mp_assert()`. See [.agents/docs/testing.md](.agents/docs/testing.md).
Backend tests run with the sasjs CLI from `sas/`. `npm run 4gl` compiles+deploys+seeds then `sasjs test -t 4gl` runs tests. Assertions use `%mp_assert()`. See [.agent/docs/testing.md](.agent/docs/testing.md).
## Conventions
@@ -88,6 +88,6 @@ Backend tests run with the sasjs CLI from `sas/`. `npm run 4gl` compiles+deploys
- No external assets - everything must be bundled and served locally.
- Comments and test names must be self-contained (no "original bug", "this PR", etc.); the only exception is a literal issue-tracker link.
- Do not auto-commit or push.
- Never edit `CHANGELOG.md` or bump `package.json` version by hand - the release pipeline generates both from Conventional Commit messages. See [.agents/docs/releases-and-changelog.md](.agents/docs/releases-and-changelog.md).
- Never edit `CHANGELOG.md` or bump `package.json` version by hand - the release pipeline generates both from Conventional Commit messages. See [.agent/docs/releases-and-changelog.md](.agent/docs/releases-and-changelog.md).
See `AGENTS.md` for the full set of enforced rules (git, linting, no-wrap, no-external-assets, `.agents/` layout).
See `AGENTS.md` for the full set of enforced rules (git, linting, no-wrap, no-external-assets, `.agent/` layout).
+10 -126
View File
@@ -4,31 +4,13 @@
## Workflow guidelines
[Wiki Page](https://git.datacontroller.io/dc/dc/wiki/Git-Workflow)
## Git hooks
The repo ships its own git hooks in [`.git-hooks/`](./.git-hooks):
- `pre-commit` - scans staged changes for secrets with gitleaks, blocks commits that would add more than 2MB of new blobs, and runs the prettier check (auto-fixing on failure)
- `commit-msg` - verifies the commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) standard
The repo `.npmrc` sets `ignore-scripts=true`, so the `prepare` and `postinstall` lifecycle scripts that would normally set `core.hooksPath` during `npm i` never run. After cloning (or if your commits are not being checked), activate the hooks with a one-time command, run from the repo root:
```bash
git config core.hooksPath ./.git-hooks
```
The pre-commit hook requires the gitleaks binary provided by the root `@nogoo9/gitleaks` devDependency, so make sure `npm i` has run in the repo root before your first commit.
The standard [pre-commit](https://pre-commit.com) route is available as well, and does not depend on the lifecycle scripts above: install it once with `pre-commit install` and the secret scan in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml) runs on every commit. The same scan also runs in CI, so a leaked credential fails the build regardless of which local hooks are active.
## Dependencies that requires licences
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
To auth for SheetJS Pro version you need to use their private registry. Keep that config out of
the repo - `client/.npmrc` is tracked and holds policy only (see below). Put the registry auth in
your user config, `~/.npmrc`:
To auth for SheetJS Pro version you need to use their private registry
Add `client/.npmrc` file with following content:
```
@sheet:registry=https://pylon.sheetjs.com:54111/
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
@@ -47,38 +29,19 @@ echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm i
npm run postinstall
```
The root and `sas/` `.npmrc` files set `ignore-scripts=true` too, and the root `package.json`
carries no install script - the root install is `npm ci` plus the subprojects, each run explicitly:
```bash
npm ci # root tooling: gitleaks, prettier, semantic-release
cd client && npm ci && npm run postinstall
cd ../sas && npm ci
```
The client `.npmrc` is now tracked and sets `ignore-scripts=true`, so dependency install hooks do not
run. Two of them matter here: the client's own `postinstall` - which generates
`src/environments/version.ts`, imported throughout the app - and cypress's binary download. Run
`npm run postinstall` after installing (and `npx cypress install` if you are running the Cypress
suite).
Registry credentials do **not** belong in `client/.npmrc`: it is a tracked policy file, and a token
written there is one `git add -A` away from being committed. Keep the token in `~/.npmrc`, npm's
default user config, or point npm at a file outside the repo with
`NPM_CONFIG_USERCONFIG=/path/to/npmrc`. The pre-commit hook rejects a staged `client/.npmrc` that
holds `_authToken`, `_auth` or `_password`.
[Handsontable](https://www.npmjs.com/package/handsontable)
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
which is published on the `hot_license_key` subject and pushed into every Handsontable instance.
Until a licence is loaded the client falls back to the non-commercial key
(`DEFAULT_HOT_LICENCE_KEY` in `client/src/app/services/licence.service.ts`), so there is nothing to
configure in `index.html` for this.
Licence should be inserted in the `client/src/index.html` file:
```
<sasjs
...
hotLicenceKey="LICENCE-GOES-HERE"
>
</sasjs>
```
## Development
Update `client/src/index.html` so that it points to your SAS9, SASVIYA or SASJS backend.
Be aware that VIYA can be configured in such way that it would not work with cross origin frontend.
@@ -91,85 +54,6 @@ cd client
npm start
```
## Mocked backend (frontend development without SAS)
Data Controller ships a full set of JS mock services under `sas/mocks/sasjs/` that mimic the SAS backend using [SASjs Server](https://server.sasjs.io) in JS-only mode. This lets you run the entire frontend (editor grid, approvals, viewer, admin, Excel maps, etc.) without a SAS licence or SAS server. The Cypress E2E tests and Lighthouse CI both run against this setup.
### Prerequisites
- Node.js (same version used for the frontend build)
- The frontend dependencies installed (`cd client && npm ci` after decrypting sheet-crypto as described above)
### 1. Start SASjs Server
Download the latest Linux (or macOS/Windows) binary from [SASjs Server releases](https://github.com/sasjs/server/releases), unzip it, and create a `.env` file alongside the executable:
```
RUN_TIMES=js
NODE_PATH=node
CORS=enable
WHITELIST=http://localhost:4200
HTTP_PORT=5000
```
`RUN_TIMES=js` means the server executes `.js` files as Stored Programs (no SAS binary needed). `CORS=enable` plus `WHITELIST` allows the Angular dev server on port 4200 to call the mock services on port 5000. Start the server:
```
./api-linux # or api-macos / api-win.exe depending on your platform
```
`HTTP_PORT` is not optional in SASjs Server 3.x: `PORT` and `PROTOCOL` are deprecated, and a server started without `HTTP_PORT` or `HTTPS_PORT` exits with "no listener is enabled". With it set, the server runs in desktop mode (no auth) on `http://localhost:5000`.
### 2. Deploy the mock services and streaming frontend
From `sas/mocks/sasjs/`:
```
npm ci
sasjs cbd -t server-ci
```
This compiles, builds, and deploys the JS mock services plus the Angular frontend (as a streaming app) to the SASjs Server. The `server-ci` target in `sas/mocks/sasjs/sasjsconfig.json` points to `http://localhost:5000` with appLoc `/Public/app/dc` and stream service name `clickme`.
The drive still needs seeding - the control library first, then the Cypress fixtures:
```
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json
sasjs request services/admin/createdemodata -t server-ci
```
After deploy, the streamed app is available at: `http://localhost:5000/AppStream/clickme/`
### 3. Run the Angular dev server (optional, for hot reload)
For frontend development with live reload, point `client/src/index.html` at the local SASjs Server and start the dev server:
```
cd client
# Edit src/index.html: set serverUrl="http://localhost:5000", appLoc="/Public/app/dc", serverType="SASJS"
npm start
```
The app will be at `http://localhost:4200`. It calls the JS mock services on port 5000 via the SASjs adapter (CORS is enabled by the `.env` above).
### How the mocks work
Each `.js` file in `sas/mocks/sasjs/services/` corresponds to a real SAS service (e.g. `public/startupservice.js` mimics `services/public/startupservice.sas`). `admin/createdemodata.js` is the exception: it has no SAS counterpart, because it exists only to give the test estate tables a deployed site would not have. The JS runtime predeclares `fs`, `_program` (the program's deployed path), `weboutPath` (the response body it writes) and `_webout`, and the services emit their members through the runtime's `webout()` - `webout('OPEN')`, one `webout('OBJ', name, rows)` (or `webout('ARR', name, rows)` for positional rows) per member, `webout('CLOSE')` - so the response envelope comes from the host and the file carries only what the SAS service returns. A few services still write the body themselves through `_webout`: the ones that emit plain text, CSV, DDL, HTML or a file download and set their own content headers (`admin/exportconfig`, `admin/exportdb`, `admin/makedata`, `auditors/getauditfile`, `public/getddl`, `public/getrawdata`). `sas/mocks/test/sasjs-webout.test.js` holds that list, so a service that goes back to writing its own body fails the suite. Some mocks are stateful: they persist data to a `mock-storage/` folder on the SASjs Drive (e.g. `licence.json`, `users.json`, `filter.txt`) using the predeclared `fs` module.
`admin/makedata` builds only what the SAS service builds: the control library defined by `mpe_makedatamodel.sas`, seeded with the rows `mpe_makedata.sas` inserts. `sas/mocks/test/makedata-parity.test.js` reads both macros and fails when the mock estate drifts from them, so the mock and a deployed site open with the same tables and the same configuration.
The fixtures the Cypress suite needs but a DC install does not have live in `admin/createdemodata`, which runs after makedata: the `TESTDATA` demo library (`MPE_X_NEW`, `MPE_X_FORMULA_TEST`, `DERIVED_DEMO`, etc.) with its validation rules (HARDREGEX, SOFTREGEX, HARDSELECT, READONLY, HIDDEN, ROUND, NUMBER_FORMAT) and dropdown values, the `MPE_X_SEARCH` and `MPE_X_WIDE` viewer fixtures, and the `DC996664` multi-load library. It is safe to run twice, and refuses to run before makedata.
There is also a SAS 9 mock variant under `sas/mocks/sas9/` (target `sas9-mocks`) that mimics SAS 9 stored processes with the same JS approach but a different appLoc (`/User Folders/sasdemo/`). Its services are fixed recordings of the SAS 9 responses, emitted through `webout()` in the same way; the payloads are the SAS 9 ones (metadata URIs, `BUILTIN\Administrators` owners, `D:\...` paths, with their separators - the front end splits on `\`, so a value that arrives without it exercises the wrong branch). Only the payloads are SAS 9 flavour: the envelope around them (`_PROGRAM`, `SYSUSERID`, `SYSVLONG`, `SYSDATE`/`SYSTIME`) is whatever runtime serves the request, so do not read this variant for SAS 9 envelope values.
### Re-deploying after changes
After changing mock services, re-run `sasjs cbd -t server-ci` from `sas/mocks/sasjs/`. After changing the frontend, rebuild (`cd client && npm run build`) then re-deploy so the streaming app picks up the new `client/dist/`. If you are using `ng serve` (step 3), only the mock services need re-deploying; the frontend hot-reloads automatically.
### Demo and staging sites
A push to `release/**` publishes [staging.datacontroller.io](https://staging.datacontroller.io); a release tag (`v*`, created by the release job after its tests pass) publishes [demo.datacontroller.io](https://demo.datacontroller.io) - workflow file `.gitea/workflows/sites.yaml`, using the `DEMO_DATACONTROLLER_IO` Surfer app password (user `allan`). Both are sites on one multi-site Surfer instance; the demo is its default site. The job builds the mocks as a browser-hosted site (SASjs CLI >= 6.1, `streamConfig.mockWeb`), so the services run in a service worker and the drive lives in the visitor's IndexedDB - there is no server. The drive the site ships with is captured from a throwaway SASjs Server as `sasjsbuild/mock-snapshot.json`, and the two sites ship different ones: the demo runs `makedata` on that server first, so it opens with the control library and the sample tables; staging publishes without running it, so it opens on the deploy screen (DC routes there while `makedata` is still on the drive) and the visitor deploys the control library from the wizard. The estate carries no licence key, so CI runs on the free tier until a key is registered through the licensing screen (`admin/registerkey`) - the Cypress setup does that for the specs that need one. The Sites job writes a licence into the published drive, so demo and staging are fully usable. The pair lives in `sas/mocks/mock-licence.json`: the two values are one RSA key and are regenerated together, and the `.gitleaksignore` entries for it are line-scoped, so inserting a line above the pair re-fires the scanner rather than revealing a new secret.
## GUI Elements
For documentation on the Clarity Design System, including a list of components and example usage, see [our website](https://vmware.github.io/clarity).
-5
View File
@@ -1,5 +0,0 @@
ignore-scripts=true
save-exact=true
# dependency install hooks are skipped: the client's own postinstall generates
# src/environments/version.ts and the hot icons, so run `npm run postinstall`
# after installing - see CONTRIBUTING.md for the full sequence
+1 -4
View File
@@ -1,10 +1,7 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"analytics": false,
"cache": {
"environment": "all"
}
"analytics": false
},
"version": 1,
"newProjectRoot": "projects",
+1 -82
View File
@@ -1,4 +1,3 @@
import fs from 'fs'
import { defineConfig } from 'cypress'
export default defineConfig({
@@ -10,7 +9,6 @@ export default defineConfig({
html: true,
json: false
},
viewportHeight: 900,
viewportWidth: 1600,
@@ -20,19 +18,13 @@ export default defineConfig({
env: {
hosturl: 'http://localhost:4200',
appLocation: '',
// The SASjs server the mock services run against, and the location the CI
// job streams the built app to (see sas/mocks/sasjs/sasjsconfig.json and
// CONTRIBUTING.md). `va-ddc-host.cy.ts` frames that build from the server's
// own /ddc-host page, so it needs both rather than `hosturl`.
sasjsServerUrl: 'http://localhost:5000',
streamedAppLocation: '/Public/app/dc',
site_id_SAS9: '70221618',
site_id_SASVIYA: '70253615',
site_id_SASJS: '123',
serverType: 'SASJS',
libraryToOpenIncludes_SASVIYA: 'viya',
libraryToOpenIncludes_SAS9: 'dc',
libraryToOpenIncludes_SASJS: 'dc_jslib',
libraryToOpenIncludes_SASJS: 'dc',
debug: false,
screenshotOnRunFailure: false,
longerCommandTimeout: 50000,
@@ -41,86 +33,13 @@ export default defineConfig({
e2e: {
video: true,
// One retry in a CI run, none interactively. `editor.cy.ts` test 28
// intermittently fails on "app-loading ... continuously found" - a slow load,
// not an assertion - and a single retry costs a few seconds where a red
// pipeline costs forty minutes. Failures that are real still fail twice, and
// the retried attempt's output is in the run log.
retries: { runMode: 1, openMode: 0 },
setupNodeEvents(on, config) {
// `download.cy.ts` clears the downloads folder before each test through
// `cy.task('deleteFolder')`. That task was registered in
// cypress/plugins/index.js, which Cypress 10+ never loads unless it is
// required from here - so the task had no handler and the spec failed on
// its first hook.
on('task', {
deleteFolder(folderName: string) {
return new Promise((resolve, reject) => {
fs.rm(folderName, { recursive: true, force: true }, (err) =>
err ? reject(err) : resolve(null)
)
})
},
/**
* Writes a copy of a fixture whose primary keys are renumbered from a
* random base, and returns the path to write it to.
*
* An upload that matches what the table already holds produces no
* changes, and a changeset with no changes has no approval waiting - so
* re-running a spec that uploads the same file finds nothing on the
* approve screen. That is not a UI fault, and a retry after a partial
* first attempt hits it every time. New keys make the upload a real
* insert whichever attempt gets there.
*/
makeUniqueExcel({ source, dest }: { source: string; dest: string }) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ExcelJS = require('exceljs')
const workbook = new ExcelJS.Workbook()
const base = 1000000 + Math.floor(Math.random() * 8000000)
return workbook.xlsx.readFile(source).then(() => {
const sheet = workbook.worksheets[0]
let rowNumber = 0
sheet.eachRow((row: any, index: number) => {
if (index === 1) return // header
row.getCell(1).value = base + rowNumber++
})
return workbook.xlsx.writeFile(dest).then(() => dest)
})
},
/** removes one file - deleteFolder would take the source fixture too */
deleteFile(filePath: string) {
return new Promise((resolve) => {
;(fs as any).rm(filePath, { force: true }, () => resolve(null))
})
}
})
// 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
})
}
@@ -1,592 +0,0 @@
// Clip script: special missings inside Data Controller's validation rules.
//
// This is not a test - it is the recording script for the companion clip, and
// it deliberately pauses between steps so each beat is readable on video. It
// lives in cypress/clips (outside cypress/e2e) so the default spec pattern does
// not pick it up in CI.
//
// Record it with:
//
// npx cypress run --spec cypress/clips/special-missings-clip.cy.ts \
// --config video=true,viewportWidth=1280,viewportHeight=720
//
// The table is TESTDATA.DEMO_01 (seven columns, one rule each):
// ID (NOTNULL), AMOUNT (MINVAL 1), SCORE (MAXVAL 100),
// REF (SOFTREGEX /^[0-9]+$/), STATUS (SOFTSELECT TESTDATA.DEMO_01.STATUS),
// RATING (no rule), LAST_REVIEWED (date9.)
//
// One editing session, one submission. The rule scenes are played on row 1 and
// each value is put back afterwards, so the DIFF that follows carries only the
// two changes the clip is about; the review screens are reached through the
// app's own navigation rather than cy.visit, so nothing reloads mid-clip.
//
// The beats are deliberately short - the take is played back at ~1.4x on the
// way out, so a hold that looks tight here reads as a normal pause on the
// finished clip.
// Caption track: each mark opens a caption and carries the text it shows.
// The encoder turns consecutive marks into subtitle cues, so the captions are
// timed by the recording itself rather than by guessed offsets.
const BEATS = '/tmp/clip-beats.tsv'
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
// A beat long enough to read on video.
const beat = (ms = 1500) => cy.wait(ms)
context('special missings clip (DEMO_01)', function () {
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// Assumes the estate is already activated (the licensing spec registers a
// valid key); otherwise a licensing page would sit in shot.
cy.get('.nav-tree', { timeout: longerCommandTimeout }).should('exist')
})
it('records the demo in one editing session', () => {
cy.writeFile(BEATS, '', { flag: 'w' })
// ---- Scene 1: the table and its rules ----------------------------------
openTableFromTree('testdata', 'demo_01')
beat(1200)
clickOnEdit(() => {
// Seven columns, five rules: ID is the key, AMOUNT the floor, SCORE the
// ceiling, REF the pattern, STATUS the dropdown, and RATING and
// LAST_REVIEWED carry nothing.
cy.get('.ht_master tbody tr', { timeout: longerCommandTimeout }).should(
'have.length.greaterThan',
3
)
mark(
'grid',
'TESTDATA.DEMO_01 - eight columns, six rules. ID is the key, and RATING and LAST_REVIEWED carry none.'
)
beat(2600)
// ---- Scene 2: typing a special missing ------------------------------
// RATING has no rule, so this shows entry on its own. Every beat asserts
// the settled cell state before holding, so the recording always rests on
// the outcome (flagged or accepted) rather than on a blind pause that
// might land before the rule engine has run.
mark(
'entry',
'A numeric cell takes a special missing as a letter or an underscore, with or without a period - row 2 already holds .a.'
)
typeAndHold(0, 'RATING', 'a', 'accepted') // a single letter is taken
mark(
'reject',
'Two letters, or a letter mixed with a number, are refused. Red means it will not submit.'
)
typeAndHold(0, 'RATING', 'AB', 'rejected', 2200) // two letters are not
typeAndHold(0, 'RATING', '1a', 'rejected', 2200) // nor a number and a letter
typeAndHold(0, 'RATING', 'a', 'accepted') // leave it as a special missing
// ---- Scene 3: NOTNULL refuses both --------------------------------
// ID is the key, so NOTNULL applies. A special missing is not a value
// here: like a blank, it fails the rule (a real SAS NOT NULL constraint
// rejects a special missing as well), and only a number satisfies it -
// the original key, put back so the row carries no change into the DIFF.
mark(
'notnull_blank',
"ID is the table's primary key, so NOT NULL is applied to it automatically - a blank fails..."
)
typeAndHold(0, 'ID', '', 'rejected', 2200) // a blank fails NOTNULL
mark(
'notnull_missing',
'...and so does a special missing: a missing is not a value to NOT NULL.'
)
typeAndHold(0, 'ID', 'A', 'rejected', 2200) // so does a special missing
mark(
'notnull_number',
'A number satisfies it, and the row key goes back.'
)
typeAndHold(0, 'ID', '1', 'accepted') // a number is what it wants
// ---- Scene 4: the pattern still applies -----------------------------
// REF carries SOFTREGEX /^[0-9]+$/, and a special missing is not exempt
// from it. The amber cell is a soft rule warning rather than a block,
// which the caption on this beat says out loud - the pattern itself is
// only visible in the cell's native title, which a screencast does not
// capture.
mark(
'softregex',
'REF carries SOFTREGEX /^[0-9]+$/. Amber is a soft warning - it warns, it does not block.'
)
typeIntoCell(0, 'REF', 'A')
getCellByHeaderAndRow(0, 'REF').should('have.class', 'dc-warning-cell')
beat(3400)
typeIntoCell(0, 'REF', '1001') // put the reference back
getCellByHeaderAndRow(0, 'REF').should(
'not.have.class',
'dc-warning-cell'
)
beat(600)
// ---- Scene 5: the dropdown lists the missing -------------------------
// STATUS carries a SOFTSELECT whose list is taken from the column itself
// (the library.member.column form), and that column holds a special
// missing - so the dropdown offers it alongside the ordinary values, as
// the bare letter SAS produces for it.
mark(
'dropdown',
"STATUS carries a SOFTSELECT, and its list is the column's own values."
)
openDropdown(0, 'STATUS')
.should('have.length', 4)
.then(($items: any) => {
const texts = [...$items].map((td: any) => td.innerText.trim())
expect(texts).to.include('A')
})
beat(2400)
mark(
'dropdown_pick',
'So the special missing the column holds is offered as a bare letter, first - a missing sorts below every number.'
)
pickFromDropdown('A')
getCellByHeaderAndRow(0, 'STATUS').should('contain.text', 'A')
beat(1800)
typeIntoCell(0, 'STATUS', '1') // put the status back
beat(600)
// ---- Scene 6: a range rule compares in SAS order, missings included
mark(
'minval',
'AMOUNT has MINVAL 1. A missing sorts below every number, so it is below the floor.'
)
typeAndHold(0, 'AMOUNT', 'A', 'rejected', 2200) // a missing is below the floor
mark(
'maxval',
'SCORE has MAXVAL 100 - the same missing is below the ceiling, so it passes.'
)
typeAndHold(0, 'SCORE', 'A', 'accepted', 2200) // a missing is below the ceiling
mark(
'grade',
'GRADE takes MINVAL .A and MAXVAL .C. The missings have an order of their own: .B is inside the range, .D is outside it.'
)
typeAndHold(0, 'GRADE', '.B', 'accepted', 2400) // .B is between .A and .C
typeAndHold(0, 'GRADE', '.D', 'rejected', 2400) // .D is above .C
mark('abort', 'Submitting with an invalid cell aborts.')
// Submit while AMOUNT is invalid - the modal reports it.
submitTable(() => {
cy.get('.modal-body', { timeout: longerCommandTimeout }).should(
'contain.text',
'Invalid Values are Present'
)
beat(2400)
// Close the abort so the editor is clean for the next scene.
cy.get('clr-modal.clr-abort-modal .modal-footer button')
.contains('Close')
.click({ force: true })
beat(800)
})
// ---- Scene 7: put the test values back, then make the real change ----
mark(
'clean',
'Back to a clean row. Row 2 already holds .a, so this changes one special missing to another.'
)
typeIntoCell(0, 'AMOUNT', '120')
typeIntoCell(0, 'SCORE', '82')
typeIntoCell(0, 'GRADE', '.a')
typeIntoCell(0, 'RATING', '4')
beat(800)
// Row 2 (ID 2) already carries a special missing in RATING, so this is a
// change from one special missing to another, and the date column gives
// the formatted / unformatted switch something to switch.
typeAndHold(1, 'RATING', 'B', 'accepted')
typeIntoDateCell(1, 'LAST_REVIEWED', '2026-01-15')
beat(1500)
submitTable(() => {
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.click()
beat(2500)
})
})
// ---- Scene 8: the queue, then the DIFF (in-app navigation) ------------
mark('submitted', 'Submitted - the queue shows it waiting for approval.')
goToReviewNav()
beat(1800)
// The submit queue lists oldest first, so the row we just created is last.
cy.get('app-submitter clr-datagrid clr-dg-row', {
timeout: longerCommandTimeout
})
.should('exist')
.last()
.click({ force: true })
beat(1800)
cy.get('app-approve-details .card', { timeout: longerCommandTimeout })
.should('exist')
.should('be.visible')
beat(1000)
// The DIFF table is wider than the frame, so the two columns that matter
// (RATING and LAST_REVIEWED) sit off the right edge until it is scrolled.
scrollDiffToEnd()
beat(1200)
// Only those two cells changed: the rule scenes were put back, so the DIFF
// is the two changes the clip is about and nothing else.
getDiffCell('RATING')
.should('contain.text', '.b')
.should('have.class', 'ch')
getDiffCell('LAST_REVIEWED')
.should('contain.text', '15JAN2026')
.should('have.class', 'ch')
cy.get('app-approve-details .tableCont tbody tr td:not(.ch)').should(
'have.length.greaterThan',
3
)
mark(
'diff',
'The DIFF compares staged with base: one changed row, two changed cells.'
)
beat(2600)
// ---- Scene 9: the staged data -----------------------------------------
// What the approval is actually acting on: the staged row, still holding
// the special missing, before it reaches the base table.
mark(
'staged',
'The staged row, before approval - still holding the special missing.'
)
clickButton('VIEW STAGED DATA')
// 'Basic Submitted Details' is on the staged screen only - asserting
// 'Staged Data' alone would be satisfied by the button that was just
// clicked, which is how a beat can pass without ever leaving the DIFF.
cy.get('body', { timeout: longerCommandTimeout })
.should('contain.text', 'Basic Submitted Details')
.and('contain.text', 'Base Table')
beat(4200)
mark('staged_end', '')
// ---- Scene 10: the approver opens it, and switches the format ----------
goToReviewNav()
beat(1500)
openApproveTab()
beat(1500)
cy.get('app-approve clr-datagrid clr-dg-row a.color-green', {
timeout: longerCommandTimeout
})
.should('exist')
.last()
.click({ force: true })
beat(1800)
cy.get('#acceptBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
beat(1000)
// Same scroll as the submitter view, so the date column is in frame when
// the format is switched.
scrollDiffToEnd()
beat(1200)
mark(
'approve',
'The approver opens the same submission. Hovering a changed cell shows the value it replaced.'
)
// Hovering the two changed cells shows what each replaced - that is how the
// two special missings are told apart, not just the before/after of one.
hoverDiffCell('RATING', 'Original value is: .a')
beat(2800)
mark('hover_date', '...including the date it replaced.')
hoverDiffCell('LAST_REVIEWED', 'Original value is: 29FEB2024')
beat(2800)
mark(
'toggle',
'The formatted / unformatted switch shows the value as SAS stores it: 24121.'
)
cy.get('.formatted-values-toggle').should('have.text', 'Formatted').click()
cy.get('.formatted-values-toggle').should('have.text', 'Unformatted')
getDiffCell('LAST_REVIEWED').should('contain.text', '24121')
getDiffCell('RATING').should('contain.text', '.b')
beat(2800)
cy.get('.formatted-values-toggle').click()
cy.get('.formatted-values-toggle').should('have.text', 'Formatted')
getDiffCell('LAST_REVIEWED').should('contain.text', '15JAN2026')
beat(1000)
// ---- Scene 11: approve, and the change is in the history --------------
mark('accepted', 'Accepted - the history records it as APPROVED.')
cy.get('#acceptBtn').click()
cy.url({ timeout: longerCommandTimeout }).should(
'include',
'/review/history'
)
cy.get('app-history clr-datagrid clr-dg-row', {
timeout: longerCommandTimeout
})
.should('exist')
.first()
.should('contain.text', 'APPROVED')
beat(2800)
})
})
// ---------------------------------------------------------------------------
// Helpers (mirrored from the e2e specs so the clip drives the same UI paths)
// ---------------------------------------------------------------------------
/**
* Caption track. Each mark opens a caption and carries the text it shows; the
* encoder turns consecutive marks into subtitle cues, so the captions are timed
* by the recording itself rather than by guessed offsets.
*
* The timestamp has to be taken inside a `cy.then()`: Cypress evaluates a
* command's arguments when the command is *queued*, so `Date.now()` passed to
* `cy.writeFile` directly would give every mark the same value - the moment the
* spec body ran.
*/
const mark = (label: string, text: string) => {
cy.then(() => {
cy.writeFile(BEATS, `${label}\t${text}\t${Date.now()}\n`, { flag: 'a+' })
})
}
const typeIntoCell = (rowIndex: number, header: string, value: string) => {
getCellByHeaderAndRow(rowIndex, header)
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type(`${value}{enter}`)
})
}
/**
* A date-formatted column edits through an HTML date input, where cy.type()
* refuses anything but a bare YYYY-MM-DD string (so no {enter} in the same
* call). Set the value through the DOM and commit it with the Enter keydown
* that Handsontable listens for.
*/
const typeIntoDateCell = (rowIndex: number, header: string, value: string) => {
getCellByHeaderAndRow(rowIndex, header)
.dblclick({ force: true })
.then(() => {
cy.focused()
.then(($i: any) => {
const el = $i[0]
el.value = value
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
})
.trigger('keydown', { key: 'Enter', keyCode: 13, which: 13 })
})
}
/**
* Opens the selectbox on a cell and returns its list entries. Handsontable
* renders the arrow itself (`.htAutocompleteArrow`); the list is a
* `.handsontable.listbox` in the app document.
*/
const openDropdown = (rowIndex: number, header: string) => {
getCellByHeaderAndRow(rowIndex, header).within(() => {
cy.get('.htAutocompleteArrow').click({ force: true })
})
return cy.get('.handsontable.listbox td', { timeout: longerCommandTimeout })
}
/** Picks an entry from the open selectbox. */
const pickFromDropdown = (value: string) => {
cy.get('.handsontable.listbox td').contains(value).click({ force: true })
}
/** Reaches the review area through the app's own navigation (no reload). */
const goToReviewNav = () => {
cy.get('.nav-link', { timeout: longerCommandTimeout })
.contains('REVIEW')
.click({ force: true })
}
/** Opens the APPROVE tab within the review area. */
const openApproveTab = () => {
cy.get('.nav-link', { timeout: longerCommandTimeout })
.contains('APPROVE')
.click({ force: true })
}
/**
* Clicks a button by its visible text. The match is case-insensitive: the app
* uppercases button labels in CSS, so the rendered text and the DOM's
* textContent differ.
*/
const clickButton = (text: string) => {
cy.contains('button', new RegExp(text, 'i'), {
timeout: longerCommandTimeout
}).click({ force: true })
}
/**
* The DIFF table is wider than the recording frame, so scroll its container to
* the end - that is what puts the changed RATING and the date column on screen.
*/
const scrollDiffToEnd = () => {
cy.get('app-approve-details .tableCont', { timeout: longerCommandTimeout })
.should('exist')
.scrollTo('right', { duration: 1200 })
}
/**
* The DIFF on the review screen is a plain HTML table (`.tableCont`), not
* Handsontable: headers are `th`, cells `td`, and a changed cell also carries
* the `ch` class whose tooltip holds the value it replaced.
*/
const getDiffCell = (headerText: string) => {
return cy
.get('app-approve-details .tableCont thead tr th', {
timeout: longerCommandTimeout
})
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => th.innerText.trim() === headerText
)
return cy
.get('app-approve-details .tableCont tbody tr')
.first()
.then(($tr: any) => $tr[0].childNodes[index])
.then((cell) => cy.get(cell))
})
}
/**
* Reveals the value a changed DIFF cell replaced, and waits until it is really
* on screen.
*
* Clarity shows the tooltip through CSS :hover, which a synthetic
* `trigger('mouseover')` does NOT activate - the tooltip stays
* `visibility: hidden`, and a `contain.text` assertion still passes because the
* text is in the DOM. So this moves the real mouse (cypress-real-events) and
* then asserts the computed style. The first real move after another action can
* be swallowed, hence the repeat; if the tooltip ever fails to appear the
* recording fails rather than quietly showing nothing.
*/
const hoverDiffCell = (headerText: string, expected: string) => {
getDiffCell(headerText).realHover()
beat(300)
getDiffCell(headerText).realHover()
getDiffCell(headerText)
.find('.tooltip-content')
.should(($t) => {
const style = getComputedStyle($t[0] as HTMLElement)
expect(style.visibility, 'tooltip visibility').to.eq('visible')
expect(Number(style.opacity), 'tooltip opacity').to.be.greaterThan(0)
expect($t[0].textContent || '', 'tooltip text').to.contain(expected)
})
}
/**
* Types a value into a cell and holds on the settled result. The rule engine
* flags the cell (htInvalid) once the edit commits, so asserting the expected
* state before the hold means the recording always rests on the outcome, and
* waits for it however long the engine takes.
*
* @param expected 'rejected' when the rule engine should flag the cell,
* 'accepted' when the value should settle unflagged.
*/
const typeAndHold = (
rowIndex: number,
header: string,
value: string,
expected: 'accepted' | 'rejected',
hold = 1800
) => {
typeIntoCell(rowIndex, header, value)
getCellByHeaderAndRow(rowIndex, header).should(
expected === 'rejected' ? 'have.class' : 'not.have.class',
'htInvalid'
)
beat(hold)
}
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => th.innerText.trim() === headerText
)
return cy
.get('.ht_master tbody tr')
.then((rows: any) => rows[rowIndex].childNodes[index])
.then((cell) => cy.get(cell))
})
}
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
const submitTable = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary')
.click()
.then(() => {
if (callback) callback()
})
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let libNode
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
libNode = node
break
}
}
cy.get(libNode).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
-165
View File
@@ -1,165 +0,0 @@
// Marks this file as an ES module so its top-level consts don't collide with
// other spec files under the TS type-checker.
export {}
//
// The client sends `browser_info` (and `browser_url_vars`, when the page URL
// has parameters) with the startup service and with the services that execute
// customer-provided code - hook scripts and dynamic cell dropdown programs.
// Other services do not receive the tables.
//
// Payloads are keyed by the _program value in the request URL, so the
// assertions can tell one service's payload from another's. Asserted on the
// adapter interface, which is the only place the payload is observable end
// to end.
//
// Each test starts from a plain visit of the app root rather than assuming
// the previous test's session: Cypress clears cookies between tests, the
// SASjs Server session drops, and the app lands back on the evaluation
// agreement card. The acceptance is guarded, so it is a no-op when the
// session survived.
const hostUrl = Cypress.env('hosturl')
/** The services the app sends diagnostics to (see DIAGNOSTICS_SERVICES in
* client/src/app/services/sas.service.ts). */
const DIAGNOSTICS_SERVICES = [
'public/startupservice',
'editors/getdata',
'editors/getdynamiccolvals',
'editors/stagedata',
'editors/loadfile',
'editors/restore',
'auditors/postdata'
]
/** Boots the app at the root, accepting the evaluation agreement if shown. */
const bootApp = () => {
cy.visit(hostUrl, { timeout: 60000 })
cy.get('body').then(($body) => {
if ($body.find('#TCS input[type="checkbox"]').length) {
cy.get('#TCS input[type="checkbox"]').check({ force: true })
cy.wait(4000)
}
})
}
/** Captures each service's payload, keyed by the _program URL parameter. */
const capturePrograms = (): Record<string, string> => {
const programs: Record<string, string> = {}
cy.intercept('**/stp/execute/**', (req) => {
// The handler must never throw: an exception inside an intercept handler
// aborts the request, which breaks the app under test. A lone '%' in the
// URL would make decodeURIComponent throw, hence the guarded parse.
let program = req.url
try {
program = decodeURIComponent(
(req.url.match(/_program=([^&]+)/) || [])[1] || ''
)
} catch (e) {
program = (req.url.match(/_program=([^&]+)/) || [])[1] || req.url
}
if (typeof req.body === 'string') {
programs[program] = req.body
} else {
programs[program] = JSON.stringify(req.body)
}
})
return programs
}
context('browser_info input table: ', function () {
it('1 | services that run no customer code do not receive it', () => {
const programs = capturePrograms()
bootApp()
// The viewer route fires public/viewlibs and a usernav service - plain
// data services with no hook scripts and no customer includes. Taken
// from a fresh boot, before anything caches the library tree.
cy.visit(`${hostUrl}/#/view/data`, { timeout: 60000 })
cy.wait(8000)
cy.then(() => {
const captured = Object.keys(programs)
// The startup service is a diagnostics service by design - it runs on
// every boot, which is the point of it - so it is excluded from this
// sweep. On a real estate it would otherwise appear here carrying
// browser_info and fail the test.
const plain = captured.filter(
(program) => !DIAGNOSTICS_SERVICES.some((s) => program.includes(s))
)
expect(
plain.length,
`non-diagnostics service calls captured (${captured.join(', ')})`
).to.be.greaterThan(0)
plain.forEach((program) => {
expect(
programs[program],
`${program} carries no browser_info`
).not.to.contain('browser_info')
})
})
})
it('2 | hook-running services receive it', () => {
const programs = capturePrograms()
bootApp()
// Opening the editor directly makes the app call editors/getdata, which
// runs the pre-edit hook and so receives the diagnostics tables.
cy.visit(`${hostUrl}/#/editor/DC_JSLIB.MPE_X_TEST`, { timeout: 60000 })
cy.get('#hotTable', { timeout: 60000 }).should('exist')
cy.wait(8000)
cy.then(() => {
const captured = Object.keys(programs)
expect(
captured.length,
`service calls captured (${captured.join(', ')})`
).to.be.greaterThan(0)
// The startup service is the other diagnostics recipient, but it is not
// reachable from a mock estate: `checkSasjsDeploy` finds makedata in
// services/admin and routes to the deploy screen instead, so the app
// never calls it on boot. Its null-payload path is covered by the unit
// spec (src/app/services/sas.service.spec.ts).
const getdata = captured.find((p) => p.includes('editors/getdata'))
expect(getdata, 'editors/getdata captured').to.not.be.undefined
expect(
programs[getdata!],
'getdata payload carries browser_info'
).to.contain('browser_info')
// TIMEZONE is a column of browser_info and of no other input table, so
// this shows the row itself arrived, not just the table name.
expect(
programs[getdata!],
'browser_info row content in the payload'
).to.contain('timezone')
})
})
it('3 | browser_url_vars carries the URL parameters as name/value pairs', () => {
const programs = capturePrograms()
bootApp()
// The labels parameter is read by the editor from the hash query string,
// so the diagnostics tables must carry it as a name/value row.
cy.visit(`${hostUrl}/#/editor/DC_JSLIB.MPE_X_TEST?labels=true`, {
timeout: 60000
})
cy.get('#hotTable', { timeout: 60000 }).should('exist')
cy.wait(8000)
cy.then(() => {
const getdata = Object.keys(programs).find((p) =>
p.includes('editors/getdata')
)
expect(getdata, 'editors/getdata captured').to.not.be.undefined
expect(
programs[getdata!],
'getdata payload carries browser_url_vars'
).to.contain('browser_url_vars')
// the labels parameter, as a name in the name/value table
expect(programs[getdata!], 'labels parameter row').to.contain('labels')
})
})
})
@@ -25,13 +25,11 @@ const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// 2. Ordinary columns (e.g. SOME_DROPDOWN) still get their rule via the
// new per-row cols[].COLTYPE path — test 2 proves the migration didn't
// silently drop real columns' rules either.
// Column positions below come from the mock's COLHEADERS
// (sas/mocks/sasjs/services/editors/getdata.js):
// "PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
// — the client-owned delete column is prepended by the editor itself (see
// deleteRecordColumn.ts), so on screen it is still column 0. childNodes[0] on
// a body <tr> is the row-header <th>, so childNodes[N+1] is the Nth
// (0-indexed) column in that list.
// Column positions below come from COLHEADERS in
// sas/mocks/sasjs/services/editors/getdata.js:
// "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,..."
// — childNodes[0] on a body <tr> is the row-header <th>, so childNodes[N+1]
// is the Nth (0-indexed) column in that list.
context('coltype vertical-array regression tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
-164
View File
@@ -1,164 +0,0 @@
export {}
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('Export DC Library DDL: ', function () {
this.beforeAll(() => {
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
it('1 | the admin action opens the flavour chooser and exports the SAS flavour by default', () => {
visitPage('system')
cy.get('.admin-action', { timeout: longerCommandTimeout })
.contains('Export DC Library DDL')
.should('exist')
cy.get('.admin-action').contains('button', 'EXPORT').should('exist')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().should('be.visible')
ddlModal().within(() => {
cy.get('select').should('have.value', 'SAS')
// the schema box only applies to the DB flavours
cy.contains('label', 'Schema (optional)').should('not.exist')
})
ddlModal().contains('button', 'Export').click()
cy.get('@open').should(
'have.been.calledWithMatch',
/\/services\/admin\/exportdb&flavour=SAS$/
)
})
it('2 | the PGSQL flavour reveals the optional schema box and passes it to the service', () => {
visitPage('system')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().within(() => {
cy.get('select').select('PGSQL')
cy.contains('label', 'Schema (optional)').should('be.visible')
cy.get('input[type="text"]').clear().type('DC')
})
ddlModal().contains('button', 'Export').click()
cy.get('@open').should(
'have.been.calledWithMatch',
/\/services\/admin\/exportdb&flavour=PGSQL&schema=DC$/
)
})
it('3 | the TSQL flavour exports without a schema when the box is left empty', () => {
visitPage('system')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().within(() => {
cy.get('select').select('TSQL')
cy.contains('label', 'Schema (optional)').should('be.visible')
})
ddlModal().contains('button', 'Export').click()
cy.get('@open').should(
'have.been.calledWithMatch',
/\/services\/admin\/exportdb&flavour=TSQL$/
)
})
it('4 | the SAS export streams the DC library DDL', () => {
visitPage('system')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().contains('button', 'Export').click()
// the URL the action opens is served by the backend - against the mocks
// that is services/admin/exportdb.js, which renders the library as basic
// DDL per flavour
cy.get('@open').then((open: any) => {
const url = open.getCall(0).args[0]
expect(url).to.match(/\/services\/admin\/exportdb&flavour=SAS$/)
cy.request(url).then((res) => {
expect(res.status).to.eq(200)
expect(res.headers['content-disposition']).to.contain('DC_JSLIB.ddl')
expect(res.body).to.contain('create table DC_JSLIB.MPE_CONFIG(')
expect(res.body).to.contain('VAR_NAME char(32)')
expect(res.body).to.contain('create table DC_JSLIB.MPE_X_TEST(')
// the SAS flavour has no schema statement and no unique index
expect(res.body).not.to.contain('CREATE SCHEMA')
expect(res.body).not.to.contain('UNIQUE INDEX')
})
})
})
it('5 | the PGSQL export honours the schema and quotes its identifiers', () => {
visitPage('system')
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.get('.admin-action').contains('button', 'EXPORT').click()
const ddlModal = () => cy.contains('clr-modal', 'Export DC Library DDL')
ddlModal().within(() => {
cy.get('select').select('PGSQL')
cy.get('input[type="text"]').clear().type('DC')
})
ddlModal().contains('button', 'Export').click()
cy.get('@open').then((open: any) => {
const url = open.getCall(0).args[0]
expect(url).to.match(/&flavour=PGSQL&schema=DC$/)
cy.request(url).then((res) => {
expect(res.status).to.eq(200)
expect(res.body).to.contain('CREATE SCHEMA DC;')
expect(res.body).to.contain('CREATE TABLE DC.MPE_CONFIG (')
expect(res.body).to.contain('"VAR_NAME" VARCHAR(32)')
expect(res.body).to.contain('CREATE UNIQUE INDEX "MPE_CONFIG_pk"')
})
})
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
@@ -1,253 +0,0 @@
// Marks this file as an ES module so its top-level consts don't collide with
// other spec files under the TS type-checker.
export {}
//
// The deploy screen's folder picker. The estate the suite runs against is
// seeded, which hides the deploy screen (makedata has removed itself) and
// refuses DIRLIST / WRITECHECK once the settings file exists - so this spec
// reports the admin folder as unconfigured to land on the deploy screen, and
// answers the two probes itself.
const hostUrl = Cypress.env('hosturl')
const username = Cypress.env('username')
const password = Cypress.env('password')
/** The folder rows the mocked DIRLIST replies with. */
const FOLDERS = [
{
FILEPATH: '/tmp/DataController/alpha',
FILENAME: 'alpha',
FILE_OR_FOLDER: 'folder',
LEVEL: 1,
MSG: ' '
},
{
FILEPATH: '/tmp/DataController/beta',
FILENAME: 'beta',
FILE_OR_FOLDER: 'folder',
LEVEL: 1,
MSG: ' '
},
{
FILEPATH: '/tmp/DataController/shut',
FILENAME: 'shut',
FILE_OR_FOLDER: 'locked',
LEVEL: 1,
MSG: 'Permission denied'
}
]
const dirResponse = () => ({
dirinfo: [
{
PARENT: '/tmp/DataController',
SEP: '/',
TRUNCATED: 'N',
PLATFORM: 'UNIX',
DEPTH: 1
}
],
dirlist: FOLDERS
})
/**
* The picker's probes, answered with a fixed listing. A probe that reaches the
* real mock would refuse: the estate is configured, and the gate in the mock
* makedata answers 500 for DIRLIST after the settings file exists.
*/
const stubProbes = () => {
cy.intercept('POST', /\/SASjsApi\/stp\/execute/, (req) => {
let program = ''
try {
program = decodeURIComponent(
(req.url.match(/_program=([^&]+)/) || [])[1] || ''
)
} catch (e) {
program = (req.url.match(/_program=([^&]+)/) || [])[1] || ''
}
if (!program.includes('admin/makedata')) {
req.continue()
return
}
const body =
typeof req.body === 'string' ? req.body : JSON.stringify(req.body)
if (body.includes('DIRLIST')) {
req.reply(dirResponse())
return
}
if (body.includes('WRITECHECK')) {
req.reply({
checkwrite: [
{
PATH: '/tmp/DataController',
PATH_EXISTS: 'Y',
IS_FOLDER: 'Y',
WRITEABLE: 'Y',
MSG: ' '
}
]
})
return
}
req.continue()
})
}
/**
* Boots the app onto the deploy screen. The admin folder is reported as
* unconfigured (makedata present), so the app routes there instead of loading
* the startup service. Readiness is the picker's Browse button appearing -
* never a fixed wait, which turns a slow runner into an intermittent failure.
*/
const bootDeployScreen = () => {
cy.intercept('GET', /\/SASjsApi\/drive\/folder/, (req) => {
let url = req.url
try {
url = decodeURIComponent(req.url)
} catch (e) {
url = req.url
}
if (url.includes('services/admin')) {
req.reply({ files: ['makedata.js'], folders: [] })
return
}
req.continue()
})
stubProbes()
cy.visit(hostUrl, { timeout: 60000 })
// Wait for the page to reach a state we can act on: a visible login form,
// the evaluation agreement, or the deploy screen. A hidden login form stays
// in the DOM on some routes, so it only counts when it is visible.
const loginVisible = ($body: any) => {
const userInput = $body.find('input.username')[0]
return !!userInput && !Cypress.dom.isHidden(userInput)
}
const deployState = ($body: any) =>
$body.find('#TCS input[type="checkbox"]').length +
$body.find('#dcDirectory-browse').length
cy.get('body').should(($body) => {
expect(
loginVisible($body) || deployState($body) > 0,
'the app rendered a state to act on'
).to.be.true
})
cy.get('body').then(($body) => {
if (loginVisible($body)) {
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
}
})
// Accept the evaluation agreement when it is shown. Wait for the page to
// reach one of the two post-boot states first - the agreement card or the
// deploy screen - so the checkbox is only looked for once it can exist.
cy.get('body', { timeout: 60000 }).should(($body) => {
expect(
deployState($body),
'the deploy screen or its agreement card rendered'
).to.be.greaterThan(0)
})
cy.get('body').then(($body) => {
if ($body.find('#TCS input[type="checkbox"]').length) {
cy.get('#TCS input[type="checkbox"]').check({ force: true })
}
})
cy.get('#dcDirectory-browse', { timeout: 60000 }).should('exist')
}
context('deploy folder picker: ', function () {
it('1 | lists folders below the picker row, not over it', () => {
bootDeployScreen()
cy.get('#dcDirectory-browse').click()
cy.get('#dcDirectory-browser', { timeout: 30000 }).should('exist')
// The geometry the overlay bug broke, asserted on the live DOM: the row
// contains everything it rendered, and the panel starts below it.
cy.get('.dc-location-input').then(($row) => {
const row = $row[0].getBoundingClientRect()
const input = $row.find('input')[0].getBoundingClientRect()
expect(input.top).to.be.at.least(row.top - 0.5)
expect(input.bottom).to.be.at.most(row.bottom + 0.5)
const buttons = $row.find('button')
for (const button of buttons) {
const rect = button.getBoundingClientRect()
expect(
rect.bottom,
`${button.textContent} sits inside the picker row`
).to.be.at.most(row.bottom + 0.5)
}
})
cy.get('#dcDirectory-browser').then(($panel) => {
const panel = $panel[0].getBoundingClientRect()
cy.get('.dc-location-input').then(($row) => {
const row = $row[0].getBoundingClientRect()
expect(
panel.top,
'the folder panel starts below the picker row'
).to.be.at.least(row.bottom - 0.5)
})
// The panel is usable, not just positioned: the listing arrived and the
// rows are inside the panel.
const rows = $panel.find('.dc-folder')
expect(rows.length, 'folders listed').to.equal(3)
for (const row of rows) {
const rect = row.getBoundingClientRect()
expect(rect.top).to.be.at.least(panel.top - 0.5)
expect(rect.bottom).to.be.at.most(panel.bottom + 0.5)
}
expect($panel[0].textContent).to.contain('alpha')
expect($panel[0].textContent).to.contain('beta')
expect($panel[0].textContent).to.contain('Permission denied')
})
})
it('2 | validates the chosen folder and keeps the row layout', () => {
bootDeployScreen()
// Set the path explicitly. The field may arrive pre-filled from SYSSCPL,
// and a blank one would make validate() short-circuit with "Type or pick a
// folder first" - a failed assertion that says nothing about the layout.
cy.get('#dcDirectory').clear().type('/tmp/DataController')
// Validate answers below the row as well, and reports the writeable
// folder - the verdict must not overlay the buttons either.
cy.get('#dcDirectory-validate').click()
cy.get('#dcDirectory-validate-result', { timeout: 30000 }).then(
($verdict) => {
const verdict = $verdict[0].getBoundingClientRect()
cy.get('.dc-location-input').then(($row) => {
const row = $row[0].getBoundingClientRect()
expect(
verdict.top,
'the write check verdict starts below the picker row'
).to.be.at.least(row.bottom - 0.5)
})
expect($verdict[0].textContent).to.contain('is writeable')
}
)
})
})
-249
View File
@@ -1,249 +0,0 @@
export {}
//
// Derived (computed) variables are configured per table in MPE_DERIVED and
// created at read time, ahead of the filter, so that they can be filtered on.
//
// TESTDATA.DERIVED_DEMO carries two of them, neither of which is a physical
// column of the table:
// REGION ifc(STATE in ('CA','OR','WA'),'West','East')
// LINE_VALUE QTY * PRICE
//
// The mock implements the derivation in getdata.js / getcolvals.js (see
// mpeDerived in dcMockUtils.js), so the EDIT screen can be exercised without a
// SAS server. The real evaluation is done by SAS.
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('derived variables: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.viewport(1440, 900)
bootApp()
})
it('1 | derived columns are shown in the EDIT grid with computed values', () => {
openEditor('TESTDATA.DERIVED_DEMO')
getCellByHeaderAndRow(0, 'REGION').should('have.text', 'West')
getCellByHeaderAndRow(1, 'REGION').should('have.text', 'East')
getCellByHeaderAndRow(0, 'LINE_VALUE').should('have.text', '21')
getCellByHeaderAndRow(1, 'LINE_VALUE').should('have.text', '99')
// REGION_LABEL is built on REGION, so it only resolves if the derived
// statements run in VARIABLE_ORDER sequence
getCellByHeaderAndRow(0, 'REGION_LABEL').should('have.text', 'West-CA')
getCellByHeaderAndRow(1, 'REGION_LABEL').should('have.text', 'East-NY')
cy.screenshot('derived-1-grid', { capture: 'viewport' })
})
it('2 | filters on a character derived column', () => {
openEditor('TESTDATA.DERIVED_DEMO')
openFilterPopup()
setFilterVariable('REGION')
setFilterValue('West')
submitFilter()
cy.get('.infoBar-text').should('contain.text', "REGION = 'West'")
cy.get('.ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should('have.length', 2)
getCellByHeaderAndRow(1, 'REGION').should('have.text', 'West')
cy.screenshot('derived-2-filter-region', { capture: 'viewport' })
})
it('3 | filters on a numeric derived column', () => {
openEditor('TESTDATA.DERIVED_DEMO')
openFilterPopup()
setFilterVariable('LINE_VALUE')
setFilterOperator('>')
setFilterValue('50')
submitFilter()
cy.get('.ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should('have.length', 1)
getCellByHeaderAndRow(0, 'LINE_VALUE').should('have.text', '99')
cy.screenshot('derived-3-filter-value', { capture: 'viewport' })
})
it('4 | a derived column is marked read-only, and opens no editor', () => {
openEditor('TESTDATA.DERIVED_DEMO')
// a derived column is read-only AND computed, so the grid marks it apart
// from an ordinary read-only column - its own header/cell class, no
// readonlyCell dimming (editor.component.ts, afterGetColHeader /
// afterGetCellMeta). STATE is the control: same grid, a physical column.
headerCell('REGION').should('have.class', 'dc-derived-header')
headerCell('REGION').should('not.have.class', 'readonlyCell')
headerCell('STATE')
.should('not.have.class', 'dc-derived-header')
.and('not.have.class', 'readonlyCell')
getCellByHeaderAndRow(0, 'REGION').should('have.class', 'dc-derived-cell')
getCellByHeaderAndRow(0, 'STATE').should(
'not.have.class',
'dc-derived-cell'
)
// The computed block opens at whichever derived column comes first in the
// grid - MPE_DERIVED order, so LINE_VALUE here, not REGION. Assert the
// invariant rather than a name: exactly one derived header carries the
// marker, and it is the first one.
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
const derived = [...$ths].filter((th) =>
th.classList.contains('dc-derived-header')
)
expect(derived.length, 'derived headers').to.be.greaterThan(0)
expect(
derived[0].classList.contains('dc-derived-first'),
'the first derived header opens the block'
).to.be.true
derived
.slice(1)
.forEach(
(th) =>
expect(
th.classList.contains('dc-derived-first'),
'only the first derived header carries the marker'
).to.be.false
)
})
// the hover text carries the label, which is what says what the column is:
// the configured VAR_LABEL where there is one, otherwise the generated
// name=expression
headerCell('REGION_LABEL')
.should('have.attr', 'title')
.and('contain', 'Region and state')
headerCell('REGION')
.should('have.attr', 'title')
.and('contain', 'REGION=ifc')
enterEditMode()
getCellByHeaderAndRow(0, 'REGION').dblclick({ force: true })
cy.get('.handsontableInput').should('not.exist')
getCellByHeaderAndRow(0, 'REGION').should('have.text', 'West')
})
/**
* The positive control for the case above: same screen, same edit mode, a
* physical column - so its silence is about the column, not the screen.
*/
it('5 | a physical column in the same grid does accept an edit', () => {
openEditor('TESTDATA.DERIVED_DEMO')
enterEditMode()
getCellByHeaderAndRow(0, 'STATE').dblclick({ force: true })
cy.get('.handsontableInput').should('exist')
})
})
/**
* Cypress clears cookies between tests, which drops the SASjs Server session
* and lands the app back on the evaluation agreement card. Accept it when it is
* shown - a no-op when the session survived.
*/
const bootApp = () => {
cy.visit(hostUrl, { timeout: longerCommandTimeout })
cy.get('body').then(($body: any) => {
if ($body.find('#TCS input[type="checkbox"]').length) {
cy.get('#TCS input[type="checkbox"]').check({ force: true })
cy.wait(4000)
}
})
}
/** Opens a table straight in the EDIT screen and waits for the grid. */
const openEditor = (libMem: string) => {
cy.visit(`${hostUrl}${appLocation}/#/editor/${libMem}`, {
timeout: longerCommandTimeout
})
cy.get('.ht_clone_top .htCore thead tr th', {
timeout: longerCommandTimeout
}).should('exist')
cy.wait(2500)
}
/** The EDIT screen opens read-only; this is the button that starts an edit. */
const enterEditMode = () => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => cy.wait(2500))
}
/** The grid header cell for a column, by its rendered text. */
const headerCell = (headerText: string) =>
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
const th = [...$ths].find(
(candidate) => (candidate as HTMLElement).innerText.trim() === headerText
)
if (!th) throw new Error(`header not found: ${headerText}`)
return cy.wrap(th)
})
const openFilterPopup = () => {
cy.contains('button.btnView', 'Filter', {
timeout: longerCommandTimeout
}).click()
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
'be.visible'
)
}
/**
* The soft-select inputs drop a transparent .overlay click-catcher over the
* modal while their suggestion list is open, so they need force.
*/
const setFilterVariable = (column: string) => {
cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true })
cy.get('#datalist_vals_var_id0_0 option').contains(column).click({
force: true
})
cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true })
cy.wait(400)
}
const setFilterValue = (value: string) => {
cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true })
cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true })
cy.wait(400)
}
const setFilterOperator = (operator: string) => {
cy.get('.filter-modal .operator-col select').first().select(operator)
cy.wait(500)
}
const submitFilter = () => {
cy.contains('.filter-modal button', 'Ok').click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
cy.wait(2500)
}
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => (th as HTMLElement).innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => (th as HTMLElement).innerText.trim() === headerText
)
return cy
.get('.ht_master tbody tr')
.then((rows: any) => rows[rowIndex].childNodes[index])
.then((cell) => cy.get(cell))
})
}
+29 -178
View File
@@ -7,123 +7,41 @@ const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
const downloadsFolder = Cypress.config('downloadsFolder')
/* set by test 1 and removed afterwards, since it is written per attempt */
let generatedExcel = ''
import { deleteDownloadsFolder } from '../util/deleteDownloadFolder'
context('download files test: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
/* the estate ships no licence, so it runs on the free tier - and the free
tier disables file upload, which is the whole point of the audit case.
Asking for a licensed estate here means the spec does not depend on
another spec having planted a key first: the key persists in the estate's
config, so whether this case could run depended on the order the specs
were sharded into. */
cy.loginAndUpdateValidKey(true)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// beforeAll already logged in. Typing into the login form unconditionally
// failed with "the center of this element is hidden from view" whenever the
// session was live and the form was not on screen, so only fill it when it
// is actually shown.
cy.get('body').then(($body) => {
const usernameInput = $body.find('input.username')[0]
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
}
})
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
visitPage('home')
})
this.afterEach(() => {
deleteDownloadsFolder()
if (generatedExcel) {
cy.task('deleteFile', 'cypress/fixtures/excels/' + generatedExcel)
generatedExcel = ''
}
})
it('1 | downloads audit file', (done) => {
/* The approve grid renders a download button per table that is waiting for
approval, and a freshly seeded estate has none, so create one: upload a
fixture to the test table and submit it from the stage screen. The row is
then found on the approve screen by the table it names, because the stage
route does not carry that table's id.
The submitted row is left behind: CI recreates the appLoc and re-runs
makedata before the specs. On an estate reused by hand the rows accumulate
and the approve grid paginates, so a row pushed past the first page would
not be found - the table name still anchors the assertion, but it cannot
rescue a row that is not on the page. */
/* the fixture is renumbered before every attempt: an upload identical to
what the table already holds produces no changes, and a changeset with no
changes leaves nothing waiting for approval. On a retry that is exactly
what happens - the first attempt's rows are already in the table - so the
keys come from a random base and the upload is always an insert. */
generatedExcel = 'generated_' + Date.now() + '.xlsx'
visitPage('approve/toapprove')
cy.task('makeUniqueExcel', {
source: 'cypress/fixtures/excels/regular_excel.xlsx',
dest: 'cypress/fixtures/excels/' + generatedExcel
})
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.btn.btn-success')
.should('be.visible')
.then((buttons) => {
buttons[0].click()
const id = buttons[0].id
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
attachExcelFile(generatedExcel)
submitExcel()
/* the stage route carries a load reference, not the table id: an upload
lands on /stage/DC20261031-201657.470_601010_4449 rather than on
/stage/<table id>, so taking the id from the URL looked for a button that
could never exist. The row is found by the table it names, and the id the
download is named after is read from the button itself. */
visitPage('review/approve')
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
cy.get('clr-datagrid clr-dg-row', { timeout: longerCommandTimeout }).should(
($rows) => {
const texts = Array.from($rows).map((row: any) =>
row.innerText.replace(/\s+/g, ' ')
)
expect(
texts.some((text: string) => text.includes('MPE_X_TEST')),
`no approval row names MPE_X_TEST - rows: ${texts
.slice(0, 5)
.join(' | ')
.slice(0, 300)}`
).to.be.true
}
)
cy.get('clr-datagrid clr-dg-row')
.filter(':contains("MPE_X_TEST")')
.first()
.find('[aria-label="Download audit file"]')
.should('have.length', 1)
.then(($btn) => {
const tableId = $btn.attr('id') as string
expect(tableId, 'the approve button carries the table id').to.be.a(
'string'
)
cy.wrap($btn).click()
checkForFileDownloaded(tableId, 'zip', () => done())
checkForFileDownloaded(id, 'zip', () => done())
})
})
})
@@ -154,7 +72,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('EXCEL')
.select('Excel')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -174,7 +92,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('SAS')
.select('SAS Datalines')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -194,7 +112,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('SAS_DDL')
.select('SAS DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -214,7 +132,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('TSQL_DDL')
.select('TSQL DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -234,7 +152,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('PGSQL_DDL')
.select('PGSQL DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -247,26 +165,6 @@ context('download files test: ', function () {
})
})
it('8 | downloads viewer PDF report', (done) => {
visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
openDownloadModal(() => {
cy.get('select')
.select('PDF')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
const id = button[0].id
checkForFileDownloaded(id, 'pdf', () => done())
})
})
})
})
this.afterEach(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
})
@@ -300,23 +198,18 @@ const checkForFileDownloaded = (
}
const openDownloadModal = (callback?: any) => {
/* filterSide is the viewer's options dropdown; it no longer carries a
dropdown-toggle class - see filter-panel.cy.ts and row-cell-limits.cy.ts.
Its entries are div[clrDropdownItem], not buttons, so select Download by its
text the way filter-panel.cy.ts selects Filter. */
cy.get('.btn.btn-sm.btn-outline.filterSide', {
timeout: longerCommandTimeout
})
cy.get('.btn.btn-sm.btn-outline.filterSide.dropdown-toggle')
.click()
.then(() => {
/* the click handler is on the clrDropdownItem element, not on the span
holding the text, and the whole label is matched: more than one entry
may mention Download, and an ambiguous cy.contains fails outright */
cy.contains('clr-dropdown-menu [clrDropdownItem]', /^\s*Download\s*$/, {
timeout: longerCommandTimeout
}).click()
cy.get('clr-dropdown-menu button').then((buttons) => {
for (let button of buttons) {
if (button.innerText.toLowerCase().includes('download')) {
button.click()
if (callback) callback()
if (callback) callback()
}
}
})
})
}
@@ -355,45 +248,3 @@ const openTableFromTree = (libNameIncludes: string, tablename: string) => {
})
})
}
const fixturePath = 'excels'
/**
* Uploads a fixture to the open table. The editor renders the file input inside
* its upload modal, which the Upload button in the button bar opens, so that
* button is clicked first - by its label, not by position, so a change to the
* bar's button order cannot break this.
*/
const attachExcelFile = (excelFilename: string) => {
cy.get('.buttonBar button', { timeout: longerCommandTimeout })
.contains('Upload')
.click()
cy.get('input[type="file"]#file-upload', { timeout: longerCommandTimeout })
.should('have.length', 1)
.attachFile(`/${fixturePath}/${excelFilename}`)
/* a parsed upload raises the info modal with this title. The modal component
is the same one aborts use, so the title is what distinguishes them: any
other text here - an abort, a validation error - fails the case with its own
message rather than being clicked through. */
cy.get('.clr-abort-modal .modal-title', { timeout: longerCommandTimeout })
.should('be.visible')
.invoke('text')
.should('include', 'Table Found')
cy.get('.clr-abort-modal .modal-footer button').contains('Close').click()
}
const submitExcel = () => {
cy.get('.buttonBar button.preview-submit', {
timeout: longerCommandTimeout
}).click()
/* the URL is already a stage route before the submit lands, so waiting on it
proves nothing; the editor leaving preview mode is the signal that the
submit was accepted */
cy.get('.buttonBar button.preview-submit', {
timeout: longerCommandTimeout
}).should('not.exist')
}
+38 -680
View File
@@ -197,7 +197,7 @@ context('editor tests: ', function () {
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
// match the excel fixtures.
it('7 | Blocks submission of a value that fails HARDREGEX', (done) => {
openTableFromTree('testdata', 'mpe_x_new')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -236,7 +236,7 @@ context('editor tests: ', function () {
})
it('8 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
openTableFromTree('testdata', 'mpe_x_new')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -280,7 +280,7 @@ context('editor tests: ', function () {
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
// SOFTFORMULA (A_COL + B_COL) computes to 12.
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -305,7 +305,7 @@ context('editor tests: ', function () {
})
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -328,7 +328,7 @@ context('editor tests: ', function () {
})
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -349,7 +349,7 @@ context('editor tests: ', function () {
})
it('12 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
openTableFromTree('testdata', 'mpe_x_new')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -376,7 +376,7 @@ context('editor tests: ', function () {
})
it('13 | Info dropdown shows only the applied HARDREGEX pattern when a column has both', () => {
openTableFromTree('testdata', 'mpe_x_new')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -396,7 +396,7 @@ context('editor tests: ', function () {
})
it('14 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
openTableFromTree('testdata', 'mpe_x_new')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -435,7 +435,7 @@ context('editor tests: ', function () {
})
it('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (done) => {
openTableFromTree('testdata', 'mpe_x_new')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -469,7 +469,7 @@ context('editor tests: ', function () {
})
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -499,7 +499,7 @@ context('editor tests: ', function () {
})
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -526,7 +526,7 @@ context('editor tests: ', function () {
})
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -562,7 +562,7 @@ context('editor tests: ', function () {
})
it('19 | Row header shows blank for unchanged, ± for a modified row', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -587,7 +587,7 @@ context('editor tests: ', function () {
})
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -614,7 +614,7 @@ context('editor tests: ', function () {
})
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -644,7 +644,7 @@ context('editor tests: ', function () {
})
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -673,19 +673,19 @@ context('editor tests: ', function () {
})
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('not.be.empty')
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('have.text', 'sasdemo')
})
})
})
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -703,7 +703,7 @@ context('editor tests: ', function () {
})
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -752,7 +752,7 @@ context('editor tests: ', function () {
// doesn't recompute the "changed from" text, it just flips which
// already-computed branch IF() reveals.
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -769,11 +769,10 @@ context('editor tests: ', function () {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL')
.invoke('text')
.should((text) => {
expect(text).to.match(/^.+ changed from orig-1$/)
})
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
'have.text',
'sasdemo changed from orig-1'
)
})
})
})
@@ -786,7 +785,7 @@ context('editor tests: ', function () {
// and proves the live-recalculated value - not a stale one - is what
// gets sent.
it('27 | Submits the computed formula value, not the raw formula string', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -836,7 +835,7 @@ context('editor tests: ', function () {
// other row's formula columns are seeded blank, so only this row is
// affected.
it('28 | A formula overwriting real pre-existing data marks the row modified, adds a comment, and can be reverted', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
// Already true on the very first render, before Edit is ever clicked -
// dataSourceUnchanged is seeded with the formula overlay at load time
@@ -894,7 +893,7 @@ context('editor tests: ', function () {
// "Revert") must keep showing the live computed value and the modified
// marker, both in the cancelled edit session and back in read-only view.
it('29 | Cancelling an edit session after a formula silently overwrote real data keeps the computed value and modified marker, not the stale raw one', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -920,7 +919,7 @@ context('editor tests: ', function () {
// getStableFormulaBaseCols, which excludes any DC.*-referencing formula
// rule from the "did a formula overwrite real data" comparison entirely.
it('30 | DC.*-referencing formula columns never get a "changed value" comment, even when their own raw seed differs from the computed result', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -938,7 +937,7 @@ context('editor tests: ', function () {
})
it('31 | Reverting a formula value then cancelling keeps it plain, and it stays plain on the next edit session', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -987,7 +986,7 @@ context('editor tests: ', function () {
// actually sent, not only formula columns - A_COL is a plain numeric
// column with no HARDFORMULA/SOFTFORMULA rule of its own.
it('32 | Editing a plain (non-formula) cell marks it overwritten, and Revert restores it', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -1016,7 +1015,7 @@ context('editor tests: ', function () {
})
it('33 | Selecting a range containing one overwritten cell shows Revert and only reverts that cell', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -1054,7 +1053,7 @@ context('editor tests: ', function () {
})
it('34 | Selecting a range with no overwritten cells does not show Revert', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -1075,7 +1074,7 @@ context('editor tests: ', function () {
})
it('35 | A selection spanning a newly-inserted row and an existing overwritten cell only reverts the existing row', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -1118,7 +1117,7 @@ context('editor tests: ', function () {
// already show their raw-vs-computed mismatch on load, with no manual
// edit needed.
it('36 | Selecting a block spanning multiple pre-loaded overwritten cells across different rows and columns reverts only the actually-overwritten ones', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -1176,7 +1175,7 @@ context('editor tests: ', function () {
})
it('37 | Selecting an entire column reverts every pre-loaded overwritten cell in it, leaving other columns and out-of-range rows untouched', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -1230,7 +1229,7 @@ context('editor tests: ', function () {
// proves the general revert path still works via a plain live edit on a
// column that's a string, not a number.
it('38 | Editing a plain character column with no DQ rule at all marks it overwritten, and Revert restores it', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -1266,7 +1265,7 @@ context('editor tests: ', function () {
// the dropdown were still closing, `.htDropdownMenu` would no longer
// exist and the `.should()` below would time out.
it('39 | Left-click or right-click inside the info dropdown does not close it', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
@@ -1292,613 +1291,6 @@ context('editor tests: ', function () {
})
})
})
// Row 1 (0-indexed): A_COL=2, B_COL=10 - see the HARDFORMULA/SOFTFORMULA
// tests' own comment above. FORMULA_SOFT_COL is a character column (see
// character-column-formula-plan.md), so it's formula-capable for pasted
// input; unlike FORMULA_HARD_COL it also accepts a user override (test 10).
it("40 | Pasting a formula with column names translates them to this row's cell references and evaluates it, on a formula-designated column", () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').click()
pasteTextIntoFocusedCell('=A_COL * B_COL')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '20')
})
})
})
// mpe_x_test has zero HARDFORMULA/SOFTFORMULA rules, but formulas are on
// for every table, not just ones that declare a formula rule - so
// paste-translation still works here too. Uses SOME_BESTNUM * 0 + 1936
// so the result is deterministic regardless of the column's actual value.
it('41 | Pasting a formula with column names on a table with zero formula rules still translates and evaluates it', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// force: true - same overlay-clone-layer reason as sortByColumn's
// own comment below; SOME_CHAR's header sort icon covers the cell.
getCellByHeaderAndRow(1, 'SOME_CHAR').click({ force: true })
pasteTextIntoFocusedCell('=SOME_BESTNUM * 0 + 1936')
getCellByHeaderAndRow(1, 'SOME_CHAR').should('have.text', '1936')
})
})
})
it('42 | Pasting a formula referencing a column name inside a quoted string leaves the quoted text untouched', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').click()
// A_COL needs a leading/trailing blank to be recognised as a
// column-name token (see substituteColumnReferences' own "no
// surrounding blanks" test) - matches the space-after-paren
// convention already used by this fixture's own CHANGE_SUMMARY_COL
// rule value ('=IF( DC.ROW_STATUS ="U",...)').
pasteTextIntoFocusedCell(
'=IF( A_COL > 0, "A_COL is positive", "negative")'
)
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
'have.text',
'A_COL is positive'
)
})
})
})
it('43 | Pasting a formula directly into an open cell editor (double-click, then paste) also translates and evaluates it', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
.dblclick({ force: true })
.then(() => {
pasteTextIntoFocusedCell('=A_COL * B_COL')
cy.focused().type('{enter}')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
'have.text',
'20'
)
})
})
})
})
// PLAIN_TEXT_COL has no DQ rule at all - not HARDFORMULA/SOFTFORMULA-
// designated, and not referenced by any formula either. Only
// backend-sourced `=`-led values are ever auto-escaped - anything the
// user pastes or types is left exactly as entered, which genuinely
// evaluates as a live formula since formulas are on for every column.
it('44 | Pasting a formula-looking value into any character column evaluates it as a real formula - user input is never auto-escaped', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').click()
pasteTextIntoFocusedCell('=100 * 100')
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '10000')
})
})
})
it('44b | Typing a formula-looking value directly into a character column evaluates it as a real formula too', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(2, 'PLAIN_TEXT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('=100 * 100{enter}')
})
getCellByHeaderAndRow(2, 'PLAIN_TEXT_COL').should('have.text', '10000')
})
})
})
// Rows 5/6/7 (0-indexed 4/5/6) come straight from the mock backend, never
// touched by a user - see getdata.js's own comment. Rows 5-6 are a
// `=`-led value that must be auto-escaped to literal text on load (not
// evaluated - only a *user's* input evaluates live, per test 44 above);
// row 7 is already apostrophe-escaped straight from the backend, which
// the app must never mistake for its own marker.
it('46 | A formula-looking value seeded from the backend into a character column is escaped to literal text on load; a genuinely-literal backend value is left untouched', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Row 5 (PK=5): references a column name (B_COL) - never
// translated (that only happens for a pasted value), so this is
// purely proving the escape, not evaluation.
getCellByHeaderAndRow(4, 'PLAIN_TEXT_COL').should(
'have.text',
'=100 + B_COL'
)
// Row 6 (PK=6): a bare arithmetic expression.
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').should(
'have.text',
'=100 + 200'
)
// Row 7 (PK=7): already apostrophe-escaped in the backend data
// itself - Handsontable's own formulas-plugin display logic strips
// exactly one leading `'` for display regardless of who put it
// there, so this also reads as literal text with no leading `'`
// visible - the difference from rows 5-6 only shows up in what
// gets submitted (test 45) and in "Apply as formula" (test 47).
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').should(
'have.text',
'=genuine literal'
)
})
})
})
// Proves requirements 2/5/6 together: an untouched auto-escaped cell
// (row 5) submits with the marker stripped back off; a genuinely-literal
// backend value (row 7) - never marked, since it never started with `=`
// in the first place - submits completely unchanged, leading `'` and all.
it('45 | Submits an untouched auto-escaped cell unmarked, and a genuinely-literal backend value completely unchanged', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const bodyText = JSON.stringify(req.body || '')
if (bodyText.includes('stagedata')) {
expect(bodyText).to.include('"PLAIN_TEXT_COL":"=100 + 200"')
expect(bodyText).not.to.include("'=100 + 200")
expect(bodyText).to.include('"PLAIN_TEXT_COL":"\'=genuine literal"')
}
req.continue()
}).as('stpExecute')
// PLAIN_TEXT_COL itself is never touched by hand on either row -
// editing each row's own A_COL instead marks both rows submittable
// (row 6 = PK 6, "=100 + 200"; row 7 = PK 7, the genuine literal)
// without disturbing PLAIN_TEXT_COL's auto-escaped/untouched state.
getCellByHeaderAndRow(5, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('60{enter}')
})
getCellByHeaderAndRow(6, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('70{enter}')
})
submitTable()
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
cy.get('#formFields_8').type('round-trip submission test')
submitTableMessage()
})
})
})
// Requirement 7: "Apply as formula" only ever appears for / acts on a
// cell the app itself auto-escaped.
it('47 | "Apply as formula" is hidden when the selection has no auto-escaped cell', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Row 7 (PK=7) is genuinely-literal, never auto-escaped.
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').click({ force: true })
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').rightclick({ force: true })
cy.get('.htContextMenu').should(($menu) => {
expect($menu.text()).not.to.include('Apply as formula')
})
})
})
})
it('48 | "Apply as formula" appears when the selection has an auto-escaped cell, and only strips the auto-inserted marker from it', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Select rows 6-7 (PK 6-7) of PLAIN_TEXT_COL - row 6 auto-escaped
// (bare expression, no column-name translation needed), row 7
// genuinely literal from the backend.
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').click({ force: true })
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').click({
force: true,
shiftKey: true
})
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').rightclick({ force: true })
cy.get('.htContextMenu').contains('Apply as formula').click()
// Row 6: marker stripped, now a real live formula.
getCellByHeaderAndRow(5, 'PLAIN_TEXT_COL').should('have.text', '300')
// Row 7: never marked, left completely untouched.
getCellByHeaderAndRow(6, 'PLAIN_TEXT_COL').should(
'have.text',
'=genuine literal'
)
})
})
})
// A user-typed formula in any character column (not just HARDFORMULA/
// SOFTFORMULA ones) is never auto-escaped and evaluates live (test 44b)
// - submission must send that computed value, not the raw formula text,
// the same way it already does for HARDFORMULA/SOFTFORMULA columns
// (test 27).
it('49 | Submits the computed value for a user-typed formula in a non-formula-rule column, not the raw formula text', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const bodyText = JSON.stringify(req.body || '')
if (bodyText.includes('stagedata')) {
expect(bodyText).to.include('"PLAIN_TEXT_COL":2')
expect(bodyText).not.to.match(/"PLAIN_TEXT_COL":"?=1\+1/)
}
req.continue()
}).as('stpExecute')
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('=1+1{enter}')
})
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '2')
submitTable()
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
cy.get('#formFields_8').type('computed formula value submission test')
submitTableMessage()
})
})
})
// B_COL is `type: numeric` (DDTYPE N) - coerceNumericRow (beforePaste)
// deliberately leaves a non-numeric, formula-looking pasted value alone
// rather than forcing it to NaN, so a live formula can end up in a
// numeric column too, not just a character one. Submission must resolve
// its computed value the same way (test 49) - the raw `=2+2` text would
// otherwise reach the backend, which a numeric SAS field can't parse.
it('50 | Submits the computed value for a live formula pasted into a numeric column, not the raw formula text', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const bodyText = JSON.stringify(req.body || '')
if (bodyText.includes('stagedata')) {
expect(bodyText).to.include('"B_COL":4')
expect(bodyText).not.to.match(/"B_COL":"?=2\+2/)
}
req.continue()
}).as('stpExecute')
getCellByHeaderAndRow(1, 'B_COL').click()
pasteTextIntoFocusedCell('=2+2')
getCellByHeaderAndRow(1, 'B_COL').should('have.text', '4')
submitTable()
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
cy.get('#formFields_8').type(
'numeric column computed formula value submission test'
)
submitTableMessage()
})
})
})
// A primary key identifies its own row for the rest of the edit session
// (dataModified/classifyRow match rows by PK) - a live, still-
// recalculating formula left sitting in a PK cell would let that
// identity drift between when it's entered and when submission finally
// resolves it, silently dropping the row from what's submitted. So
// unlike B_COL above, a formula pasted into PRIMARY_KEY_FIELD resolves
// to its computed value immediately, the moment the paste completes,
// rather than staying live until submit. Row 4 (PK=5): pasting
// `=999+1` evaluates to 1000, a value clearly different from both the
// original and every other row's PK, so a passing assertion here can't
// be a coincidence (nor a PK collision with another seeded row).
it('51 | Resolves a formula pasted into the primary key column to its computed value immediately', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const bodyText = JSON.stringify(req.body || '')
if (bodyText.includes('stagedata')) {
expect(bodyText).to.include('"PRIMARY_KEY_FIELD":1000')
}
req.continue()
}).as('stpExecute')
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '5')
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').click()
pasteTextIntoFocusedCell('=999+1')
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should(
'have.text',
'1000'
)
submitTable()
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
cy.get('#formFields_8').type(
'primary key computed formula value submission test'
)
submitTableMessage()
})
})
})
// Nothing gates a HARDFORMULA/SOFTFORMULA rule's BASE_COL by column type
// or PK-ness - MPE_X_FORMULA_PK_TEST's composite PK is itself made up of
// a HARDFORMULA numeric column and a SOFTFORMULA character column. Left
// unresolved, that reproduces the same PK-identity bug test 51 fixes -
// just triggered by the rule seeding instead of a user edit, and present
// on every row from the very first render rather than only an edited
// one. resolvePkFormulaSeedValues (editor.component.ts) freezes both PK
// cells to their computed values once, right after the initial table
// load, so they display correctly immediately and the row still submits
// - editing A_COL afterward (which the PK formulas reference) must NOT
// retroactively recompute either PK cell, proving they're frozen rather
// than still-live.
it('52 | A primary key made of HARDFORMULA/SOFTFORMULA columns freezes to its computed value at load and still submits correctly', () => {
openTableFromTree('testdata', 'mpe_x_formula_pk_test')
// Row 0: A_COL=2, B_COL=3 -> PK_HARDFORMULA_COL=6, PK_SOFTFORMULA_COL="PK-2".
getCellByHeaderAndRow(0, 'PK_HARDFORMULA_COL').should('have.text', '6')
getCellByHeaderAndRow(0, 'PK_SOFTFORMULA_COL').should('have.text', 'PK-2')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
const bodyText = JSON.stringify(req.body || '')
if (bodyText.includes('stagedata')) {
// The row must not be silently dropped from what's submitted
// - both frozen PK values and the actually-edited column must
// be present.
expect(bodyText).to.include('"PK_HARDFORMULA_COL":6')
expect(bodyText).to.include('"PK_SOFTFORMULA_COL":"PK-2"')
expect(bodyText).to.include('"A_COL":9')
}
req.continue()
}).as('stpExecute')
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('9{enter}')
})
// Frozen, not live - A_COL changing from 2 to 9 would recompute
// PK_HARDFORMULA_COL to 27 if it were still a live formula.
getCellByHeaderAndRow(0, 'PK_HARDFORMULA_COL').should('have.text', '6')
submitTable()
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
cy.get('#formFields_8').type(
'formula-driven composite primary key submission test'
)
submitTableMessage()
})
})
})
// afterChange delivers Handsontable's own VISUAL row - the EDIT_STATUS/
// overwritten-comment sync hook must translate that to physical before
// touching dataSource (classifyRow, PK-matching), and the two methods it
// calls must translate back to visual for their own Handsontable/
// comments-plugin writes. ROW_STATUS_COL (=DC.ROW_STATUS) makes the
// EDIT_STATUS write directly observable, so sorting first (breaking
// visual/physical alignment) and editing the row that moved proves the
// fix end-to-end, not just that an unsorted grid still works.
it('53 | Editing a row after sorting updates DC.ROW_STATUS on that same row, not whatever row now visually sits in its old position', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
sortByColumn('PLAIN_TEXT_COL')
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
const pkColIndex = [...$ths].findIndex(
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
)
const statusColIndex = [...$ths].findIndex(
(th) => th.innerText.trim() === 'ROW_STATUS_COL'
)
cy.get('.ht_master tbody tr').then((rows: any) => {
const visualRow = [...rows].findIndex(
(row: any) => row.childNodes[pkColIndex].innerText.trim() === '1'
)
// Rows 7-10 (their own pre-existing FORMULA_HARD_COL/
// FORMULA_SOFT_COL data, silently overwritten by the DQ rule -
// see test 28) already read 'M' from the very first render, so
// asserting a specific OTHER row is 'U' would be a coincidence
// of seed data, not a proof of anything. Snapshot every other
// row's own status instead, and assert none of them changed -
// that's what actually distinguishes "the edit landed on the
// right row" from "it landed on whatever moved into that
// visual slot".
const beforeStatuses = [...rows].map((row: any, i: number) =>
i === visualRow
? null
: row.childNodes[statusColIndex].innerText.trim()
)
getCellByHeaderAndRow(visualRow, 'ROW_STATUS_COL').should(
'have.text',
'U'
)
getCellByHeaderAndRow(visualRow, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
// The edited row (PK=1) shows 'M' at its own visual position...
getCellByHeaderAndRow(visualRow, 'ROW_STATUS_COL').should(
'have.text',
'M'
)
// ...and every other row's status is exactly what it was
// before - none of them got the write meant for row PK=1.
cy.get('.ht_master tbody tr').should((rows2: any) => {
;[...rows2].forEach((row: any, i: number) => {
if (i === visualRow) return
const status = row.childNodes[statusColIndex].innerText.trim()
expect(status, `row ${i} status unchanged`).to.equal(
beforeStatuses[i]
)
})
})
})
})
})
})
})
// multiColumnSorting's own sort() replaces its entire sort state on every
// call rather than accumulating (see its own JSDoc) - restoring a
// captured multi-column sort with a loop of single-column sort() calls
// therefore silently drops every column but the last one restored. This
// hits cancelEdit() specifically once a user has shift-clicked a second
// header to sort by two columns, then clicks Cancel: the underlying bug
// is already covered directly against the Handsontable plugin in
// sortedGridRowSync.integration.spec.ts, but only a real shift-click
// through the actual grid proves editor.component.ts's own cancelEdit()
// restores it correctly end-to-end.
it('54 | Cancelling an edit after sorting by two columns keeps both columns sorted, not just the last one clicked', () => {
openTableFromTree('testdata', 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
sortByColumn('A_COL')
sortByColumnAdditional('B_COL')
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
const pkColIndex = [...$ths].findIndex(
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
)
cy.get('.ht_master tbody tr').then((rows: any) => {
const beforeCancelPkOrder = [...rows].map((row: any) =>
row.childNodes[pkColIndex].innerText.trim()
)
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).click()
// cancelEdit() re-renders read-only with the same dataSource,
// still ordered by the restored sort - the row order itself is
// the only observable signal that both columns' sort survived.
cy.get('.ht_master tbody tr').should((rows2: any) => {
const afterCancelPkOrder = [...rows2].map((row: any) =>
row.childNodes[pkColIndex].innerText.trim()
)
expect(afterCancelPkOrder).to.deep.equal(beforeCancelPkOrder)
})
})
})
})
})
})
})
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
@@ -1928,19 +1320,6 @@ const sortByColumn = (headerText: string) => {
.click({ force: true })
}
// Shift-clicks a column header's sort indicator to ADD it to whatever sort
// is already active, rather than replacing it - Handsontable's own
// multiColumnSorting UI convention for building a multi-column sort (a
// plain click, sortByColumn above, always starts a fresh single-column
// sort instead).
const sortByColumnAdditional = (headerText: string) => {
cy.get('.ht_clone_top .htCore thead tr th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.find('.columnSorting')
.click({ force: true, shiftKey: true })
}
// Opens a column header's dropdown menu to reach its `info` item, which
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
@@ -1973,27 +1352,6 @@ const openColumnDropdown = (headerText: string) => {
// re-renders virtualized columns asynchronously after a scroll event, on
// its own schedule outside Cypress's command queue, so scrollTo() settling
// does not mean the target column has been rendered yet.
// Simulates a real clipboard paste into whichever element currently has
// DOM focus (Handsontable moves focus to its own internal textarea once a
// cell is selected/clicked) - Cypress has no built-in clipboard simulation,
// and Handsontable's CopyPaste plugin only listens for a native 'paste'
// DOM event with clipboardData, so a synthetic ClipboardEvent is
// dispatched directly rather than trying to drive the OS clipboard.
const pasteTextIntoFocusedCell = (text: string) => {
cy.focused().then(($el) => {
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', text)
const pasteEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
$el[0].dispatchEvent(pasteEvent)
})
}
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
+17 -437
View File
@@ -6,90 +6,44 @@ const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
// - chrome is hidden (same as embed=true),
// - the editor opens READ-ONLY (the user clicks Edit, as in normal use),
// - the Filter button is hidden - VA filters replace the filter builder,
// - the Add Row / Add Record buttons are hidden.
// - the editor opens in edit mode immediately (not read-only),
// - the only action button is a single Submit below the grid (stubbed in v1),
// - the top button bar and Add Record are hidden.
//
// The detailed PK+label data-merge logic is covered by the VaMessagingService
// unit spec; here we assert the deterministic UI layout, that pushing a VA
// postMessage does not break the page, and (case 3) that a `va_parmap` URL
// mapping filters the column it names end to end.
/**
* Assert on the row count the editor shows. The assertion runs inside a Cy
* `should`, so it retries until the grid has loaded: reading the text once would
* capture the count from before a filter is applied.
*/
const parseRows = (text: string): number => {
const match = /\(([\d,]+) rows?/.exec(text)
return match ? Number(match[1].replace(/,/g, '')) : NaN
}
const expectRows = (check: (rows: number) => void) =>
cy.get('h4', { timeout: longerCommandTimeout }).should(($h4) => {
check(parseRows($h4.text()))
})
/** Assert the grid shrank, without pinning a fixture-specific number. */
const expectFewerRowsThan = (baseline: number) =>
expectRows((rows) => {
expect(rows, 'filtered row count').to.be.lessThan(baseline)
})
/** Read the unfiltered row count from the app itself, retrying until it loads. */
const readRowCount = (): Cypress.Chainable<number> =>
expectRows((rows) => {
expect(rows, 'row count').to.be.greaterThan(0)
})
.get('h4')
.invoke('text')
.then(parseRows)
// unit spec; here we only assert the deterministic UI layout and that pushing a
// VA postMessage does not break the page.
context('embed=va (VA data-driven content) tests: ', function () {
// How many rows the table has unfiltered. Captured once, from the app itself.
let unfilteredRows = 0
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
// Baseline for the relative row-count assertions: open the table with no VA
// message and record what the editor itself reports.
cy.visit(`${hostUrl}${appLocation}/#/editor/DC_JSLIB.MPE_X_TEST`, {
timeout: 60000
})
readRowCount().then((count) => {
unfilteredRows = count
})
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// A cy.visit that differs from the current URL only in the hash is a
// same-document navigation, so the app is not re-bootstrapped. Reload so
// each test starts from a fresh document (the previous test may have left
// ?embed=va in the URL, which the app reads once at init).
cy.reload()
visitPage('home')
})
it('1 | opens the editor in VA mode: chrome hidden, read-only, no Filter button', () => {
it('1 | opens the editor in VA mode: chrome hidden, editable, single bottom Submit', () => {
openEditorInVaMode('mpe_x_test', () => {
// Chrome hidden (same as embed=true)
cy.get('header.app-header').should('not.exist')
// Grid present, read-only: the user enters edit mode with Edit, as usual
// Grid present and in edit mode (read-only Filter/Edit/Upload bar absent)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl button').contains('Edit').should('exist')
// The filter builder is replaced by VA filters
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
// Not in edit mode, so no edit-mode controls
// Top edit-mode buttons hidden in VA mode
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
cy.get('.btnCtrl').contains('Submit').should('not.exist')
// Bottom Add Record hidden in VA mode
// Bottom Add Record hidden
cy.contains('button', 'Add Record').should('not.exist')
// Exactly one Submit button, below the grid, disabled in v1
cy.get('button').filter(':contains("Submit")').should('have.length', 1)
cy.get('button').contains('Submit').should('be.disabled')
})
})
@@ -115,382 +69,14 @@ context('embed=va (VA data-driven content) tests: ', function () {
// Editor still loads in VA mode after the message (chrome hidden, grid up)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('header.app-header').should('not.exist')
cy.get('.btnCtrl button').contains('Edit').should('exist')
})
})
it('3 | va_parmap filters a column from a parameter that is not its name (tab 1)', () => {
// Two DC instances over one table (one per report tab), each filtering the
// same DC column from its own report parameter. Without the URL mapping the
// parameter label is not a column name, so nothing would filter at all. The
// mapping here is written in lower case against an upper-case label and
// column, which pins the case-insensitivity of both sides end to end.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
// The applied filter names the MAPPED column, and the grid shrinks to
// the rows matching that value (Option 2 occurs twice in this table).
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DROPDOWN'
)
cy.get('.infoBar-text').should('contain.text', "'Option 2'")
expectFewerRowsThan(unfilteredRows)
},
'va_parmap=dropdown_tab1-some_dropdown'
)
})
it('4 | the other tab maps its own parameter to the same column', () => {
// Same table, same column, a different parameter label and value (Option 1
// occurs four times) - the per-instance URL mapping is what keeps the two
// tabs independent.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 1'
})
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DROPDOWN'
)
cy.get('.infoBar-text').should('contain.text', "'Option 1'")
expectFewerRowsThan(unfilteredRows)
},
'va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
)
})
it('5 | warns in the grid when a mapping names a column the table lacks', () => {
// Written by hand in the report's URL, so a typo must be visible to the
// report author: the parameter is skipped, and the reason is shown rather
// than leaving an unfiltered grid that looks like a working filter.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'VA parameter mapping problem'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
cy.get('.va-parmap-warning').should('contain.text', 'NOT_A_COLUMN')
// VA mode hides the app header, so the banner must paint inside the
// viewport on its own - assert the real box, not just its presence.
cy.get('.va-parmap-warning').should(($alert) => {
const rect = $alert[0].getBoundingClientRect()
expect(rect.width, 'banner width').to.be.greaterThan(100)
expect(rect.height, 'banner height').to.be.greaterThan(20)
expect(rect.top, 'banner top').to.be.at.least(0)
expect(rect.bottom, 'banner bottom').to.be.at.most(
Cypress.config('viewportHeight')
)
})
cy.screenshot('va-parmap-banner', { capture: 'viewport' })
// Nothing was filtered: the row count matches the unfiltered baseline.
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
},
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN'
)
})
it('6 | warns straight away when a mapping entry cannot be parsed', () => {
// No separator, so the entry never becomes a mapping - reported before any
// VA message arrives.
openEditorInVaMode(
'mpe_x_test',
() => {
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'it needs a "-" between the VA parameter and the column name'
)
},
'va_parmap=DROPDOWN_TAB1'
)
})
it('7 | warns when a mapping names a parameter VA is not sending', () => {
// Parameter-side typo: the mapping never applies, so without the banner this
// is indistinguishable from a working filter over every row.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
// One message only tells us this object's parameter set is still being
// discovered; the warning waits for a message that introduces no new name.
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'not.exist'
)
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'names VA parameter "dropdown_tab_1", which VA is not sending'
)
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
},
'va_parmap=DROPDOWN_TAB_1-SOME_DROPDOWN'
)
})
it('8 | applies a mapping from the buffered message on the very first load', () => {
// VA posts once on iframe load, before the app bootstraps, so the first
// getdata is already filtered from that buffered message - with no column
// metadata available, the mapping is what names the column.
visitWithBufferedMessage(
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN',
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
},
() => {
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
"SOME_DROPDOWN = 'Option 2'"
)
expectFewerRowsThan(unfilteredRows)
cy.url().should('match', /\/editor\/[^/]+\/\d+/)
}
)
})
it('9 | a bad mapping on the first load still ends at the banner', () => {
// The deferred first load cannot check the column against metadata, so its
// saveQuery may fail and fall back to an unfiltered load - the banner is
// what stops that reading as a working filter.
visitWithBufferedMessage(
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN',
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
},
() => {
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'NOT_A_COLUMN'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
// Deliberately no row-count assertion: this path cannot validate the
// column (no metadata yet), so the outcome depends on the platform - on
// SAS, mp_filtercheck aborts the save and the load falls back to
// unfiltered; the JS mock stores the filter and returns 0 rows. The
// banner is the part that must hold either way, because both outcomes
// are otherwise indistinguishable from a working filter.
}
)
})
it('10 | warns when two parameters filter the same column (an empty grid)', () => {
// Clauses are AND-ed, so two different values on one column produce no rows
// at all - the last silent-empty-grid path. Reported rather than rewritten,
// so the author decides whether to use one parameter carrying both values.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage(
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 1'
},
{
name: 'pr2',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 2'
}
)
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'combined with AND'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB2')
// The consequence: rows must match both clauses, and the two values
// differ, so none can match.
expectRows((rows) => expect(rows, 'matching rows').to.equal(0))
},
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN&va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
)
})
it('12 | a transform adds the previous quarter to the filter', () => {
// ADD(QUARTER_START,-1) is the reported requirement: one clause carrying the
// selected date and the same point one quarter earlier. 2026-10-01 = 24380,
// 2026-07-01 = 24288 in SAS days; nothing in the fixture matches either, so
// the assertion is on the clause rather than the row count.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'SELECTED_DATE',
dataType: 'string',
value: '01OCT2026'
})
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DATE IN ('
)
cy.get('.infoBar-text').should('contain.text', '24380')
cy.get('.infoBar-text').should('contain.text', '24288')
cy.get('h4').should('contain.text', '(0 rows')
},
'va_parmap=SELECTED_DATE-SOME_DATE:ADD(QUARTER_START,-1)'
)
})
it('13 | warns when a transform lands on a column it cannot apply to', () => {
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'SELECTED_DATE',
dataType: 'string',
value: 'x'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'is not a date or datetime column'
)
cy.get('h4').should('contain.text', '(7 rows')
},
'va_parmap=SELECTED_DATE-SOME_CHAR:ADD(QUARTER_START,-1)'
)
})
it('11 | logs a parse-time problem once, however many messages arrive', () => {
// The parse problem is logged at app init; the editor's own loop must not log
// it again. The second entry is valid-but-missing, so the loop definitely runs
// and logs its own line - which is what makes this a real assertion.
cy.visit(
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&va_parmap=DROPDOWN_TAB1&va_parmap=DROPDOWN_TAB2-NOT_A_COLUMN`,
{
onBeforeLoad(win: any) {
win.__warns = []
const original = win.console.warn.bind(win.console)
win.console.warn = (...args: any[]) => {
win.__warns.push(args.join(' '))
original(...args)
}
}
}
)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
pushVaMessage({
name: 'pr2',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'NOT_A_COLUMN'
)
cy.window().then((win: any) => {
const warns: string[] = win.__warns || []
const parseLines = warns.filter((w) =>
w.includes('va_parmap "DROPDOWN_TAB1"')
)
const runtimeLines = warns.filter((w) => w.includes('NOT_A_COLUMN'))
expect(parseLines.length, warns.join(' | ')).to.eq(1)
expect(runtimeLines.length, warns.join(' | ')).to.eq(1)
cy.get('button').contains('Submit').should('exist')
})
})
})
/**
* Loads the editor with a VA message already in the pre-bootstrap buffer, the
* way VA's own postMessage arrives before Angular starts. A distinct pathname is
* used so this is a real document load: a cy.visit that differs only in the hash
* is a same-document navigation and would not re-bootstrap the app.
*/
const visitWithBufferedMessage = (
extraParams: string,
parameter: any,
callback?: any
) => {
cy.visit(
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&${extraParams}`,
{
onBeforeLoad(win: any) {
win.__vaLastMessage = {
t: Date.now(),
origin: win.location.origin,
data: {
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: [parameter]
}
}
}
}
)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
if (callback) callback()
}
/** Pushes one VA data-driven content message carrying the given parameters. */
const pushVaMessage = (...parameters: any[]) => {
cy.window().then((win) => {
win.postMessage(
{
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters
},
'*'
)
})
}
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
// re-visits the same route with ?embed=va so the app parses VA embed mode.
// `extraParams` is appended to that query string (e.g. va_parmap entries).
const openEditorInVaMode = (
tablename: string,
callback?: any,
extraParams?: string
) => {
const openEditorInVaMode = (tablename: string, callback?: any) => {
openTableFromTree(libraryToOpenIncludes, tablename)
cy.get('#hotTable', { timeout: longerCommandTimeout })
@@ -498,13 +84,7 @@ const openEditorInVaMode = (
.then(() => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
const extra = extraParams ? `&${extraParams}` : ''
cy.visit(`${url}${separator}embed=va${extra}`)
// A cy.visit that differs from the current URL only in the hash is a
// same-document navigation, so the app is not re-bootstrapped and the
// embed parameter (read once at app init) never takes effect. Reload to
// bootstrap it, as the VA iframe load does.
cy.reload()
cy.visit(`${url}${separator}embed=va`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
-50
View File
@@ -49,16 +49,6 @@ context('excel multi load tests: ', function () {
undefined,
(includes: boolean) => {
if (includes) {
// MPE_TABLES is left out of the submission, and the pane
// says why - as a sheet that could not be read or as one
// with no rows, depending on what the sheet utility
// reported. Either way it is named.
cy.get('#file-extraction-failures, #file-empty-sheets', {
timeout: longerCommandTimeout
})
.should('be.visible')
.and('contain.text', mpeTablesTable)
// MPE_TABLES sheet does not have data so 1 error image must be shown
hasErrorTables(1, (valid: boolean) => {
if (valid) done()
@@ -203,46 +193,6 @@ context('excel multi load tests: ', function () {
})
})
// The file's own status line: size and stage. The stages are momentary, so
// the dataset lookups are held open to make "Fetching the dataset details"
// assertable (and visible in the recording).
it('4 | Shows the file size and the loading stage while the file is processed', () => {
attachExcelFile('multi_load_test_1.xlsx', () => {
cy.contains('File size:')
.should('be.visible')
.invoke('text')
.should('match', /File size:\s*[\d.]+ MB/)
// The stage line lives in the file pane until the datasets are fetched,
// then in the no-table-selected pane while the sheets are extracted, so
// both ids are asked for - the element that carries it changes.
const STAGE = '#file-loading-stage, #file-extraction-stage'
// The sheet scan is quick; from then on the file waits for the user.
cy.get(STAGE, { timeout: longerCommandTimeout })
.should('be.visible')
.and('contain.text', 'File ready to load')
// The adapter posts to /SASjsApi/stp/execute/?_program=... - a glob on
// 'execute**' does not match the trailing slash, so match on the path.
cy.intercept({ url: /SASjsApi\/stp\/execute/ }, (req) => {
req.on('response', (res) => {
res.setDelay(1500)
})
})
cy.get('#continue-btn').trigger('click')
cy.get(STAGE, { timeout: longerCommandTimeout })
.should('be.visible')
.and('contain.text', 'Fetching the dataset details')
// Once every sheet has been extracted the stage gives way to the hint.
cy.get(STAGE, { timeout: longerCommandTimeout }).should('not.exist')
cy.contains('Please select a dataset on the left').should('be.visible')
})
})
this.afterEach(() => {
colorLog(`TEST END -------------`, '#3498DB')
})
+1 -74
View File
@@ -399,66 +399,6 @@ context('excel tests: ', function () {
})
})
it('25 | Keeps decimals when the upload preview becomes a manual edit', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// Entering manual edit mode discards the source file but keeps the values
// parsed from it. SOME_NUM holds 0.901045951946194 in the first data row
// of the fixture, so a truncated or zeroed cell fails the assertion.
attachExcelFile('regular_excel.xlsx', () => {
getFirstRowCells().then((previewCells) => {
expect(previewCells).to.include(decimalSomeNum)
cy.get('.btn-upload-preview.btn-primary').click()
cy.get('.modal-footer .btn-success-outline').click()
getFirstRowCells().then((editCells) => {
expect(editCells).to.include(decimalSomeNum)
})
})
})
})
it('26 | Discarding an upload that fails validation clears the red cells', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// The loaded table's row count is what the grid must return to - the
// preview replaces the data source with the parsed file.
cy.get('h4.editor-title', { timeout: longerCommandTimeout })
.invoke('text')
.should('match', /\(\d+ rows?/)
.then((titleBefore) => {
const rowsBefore = titleBefore.match(/\(([\d,]+) rows?/)?.[1]
attachExcelFile('mixed_content_excel.xlsx', () => {
// The preview paints the cells that fail validation red - the state
// the user is looking at when they decide to abandon the file.
cy.get('#hotTable .htInvalid', {
timeout: longerCommandTimeout
}).should('have.length.greaterThan', 0)
cy.get('.buttonBar .btn-upload-preview.btn-outline-danger').click()
cy.get('clr-modal .modal-footer .btn-success-outline').click()
// Back to the loaded table, with no red cells left over. cancelEdit
// re-runs the HOT validation after restoring the data; without that
// the invalid cell meta survives the restore and the grid stays red.
cy.get('h4.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
`(${rowsBefore} rows`
)
cy.get('#hotTable .htInvalid').should('not.exist')
// And the page is back in its normal read-only state, not the
// preview's button bar.
cy.get('.btnCtrl .btn-primary', {
timeout: longerCommandTimeout
}).should('contain.text', 'Edit')
})
})
})
// Large files break Cypress
// it ('? | Uploads Excel with size of 5MB', (done) => {
@@ -536,19 +476,6 @@ const attachExcelFile = (excelFilename: string, callback?: any) => {
})
}
const decimalSomeNum = '0.901045951946194'
// The row's cell texts, without depending on column positions: the preview
// grid renders row-status and delete cells in an order that varies.
const getFirstRowCells = () =>
cy
.get('#hotTable', { timeout: longerCommandTimeout })
.find('div.ht_master.handsontable tbody tr')
.eq(0)
.then(($row) =>
Array.from($row[0].children).map((cell: any) => cell.innerText.trim())
)
const submitExcel = (callback?: any) => {
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
.click()
@@ -619,7 +546,7 @@ const checkResultOfFormulaUpload = (callback?: any) => {
.find('tbody')
.then((data) => {
const cell: any = data[0].children[0].children[5]
expect(cell.innerText).to.equal('2')
expect(cell.innerText).to.equal('=1+1')
if (callback) callback()
})
}
-257
View File
@@ -1,257 +0,0 @@
export {}
//
// The applied-filter panel shows the clause that is in force. A long clause
// does not fit the collapsed single line, so the panel carries a chevron that
// expands it to show the clause in full - in the viewer and in the editor.
//
// The chevron is only rendered when the clause actually overflows, so a short
// filter looks exactly as it did before.
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('applied filter panel: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
cy.loginAndUpdateValidKey(true)
})
// the panel behaviour depends on how much room the clause has, so pin a
// laptop-sized window rather than the wider CI viewport
this.beforeEach(() => {
cy.viewport(1280, 800)
bootApp()
})
it('1 | without a filter the panel is not rendered', () => {
openTable('DC_JSLIB.MPE_X_TEST')
cy.get('.infoBar').should('not.exist')
cy.screenshot('filter-panel-1-no-filter', { capture: 'viewport' })
})
it('2 | a short filter is shown in full, with no chevron', () => {
openTable('DC_JSLIB.MPE_X_TEST')
openFilterPopup()
setFilterVariable('SOME_DROPDOWN')
setFilterValue('Option 1')
submitFilter()
cy.get('.infoBar-text').should('contain.text', "SOME_DROPDOWN = 'Option 1'")
// the clause fits the collapsed line, so there is nothing to expand
assertClauseFitsCollapsedLine()
cy.get('.infoBar-toggle').should('not.exist')
cy.screenshot('filter-panel-2-filter', { capture: 'viewport' })
})
it('3 | a long filter is collapsed to a chevron and expands to the full clause', () => {
openTable('DC_JSLIB.MPE_X_TEST')
// SOME_CHAR holds a very long free-text value, so an IN over every value
// produces a clause of several hundred characters
openFilterPopup()
setFilterVariable('SOME_CHAR')
setFilterOperator('IN')
chooseAllFilterValues()
submitFilter()
cy.get('.infoBar-text').should('contain.text', 'SOME_CHAR IN')
assertClauseOverflowsCollapsedLine()
cy.get('.infoBar-toggle')
.should('exist')
.and('have.attr', 'aria-expanded', 'false')
cy.screenshot('filter-panel-3-big-filter-collapsed', {
capture: 'viewport'
})
// expand: the whole clause is rendered, on several lines, unclipped
cy.get('.infoBar-toggle').click()
cy.get('.infoBar').should('have.class', 'expanded')
cy.get('.infoBar-toggle').should('have.attr', 'aria-expanded', 'true')
assertClauseFullyVisible()
assertClauseSpansSeveralLines()
cy.screenshot('filter-panel-3-big-filter-expanded', { capture: 'viewport' })
// collapse again
cy.get('.infoBar-toggle').click()
cy.get('.infoBar').should('not.have.class', 'expanded')
assertClauseOverflowsCollapsedLine()
})
it('4 | the editor panel collapses and expands in the same way', () => {
openTable('DC_JSLIB.MPE_X_TEST')
openFilterPopup()
setFilterVariable('SOME_CHAR')
setFilterOperator('IN')
chooseAllFilterValues()
submitFilter()
// the editor shows the filter the viewer just stored
cy.url().then((url) => {
const filterId = url.split('/').pop()
cy.visit(
`${hostUrl}${appLocation}/#/editor/DC_JSLIB.MPE_X_TEST/${filterId}`,
{ timeout: longerCommandTimeout }
)
})
// wait for the editor itself - .editor-title/.btnCtrl are editor-only, so
// this cannot pass on the viewer the visit came from
cy.get('.editor-title', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl', { timeout: longerCommandTimeout }).should('exist')
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_CHAR IN'
)
assertClauseOverflowsCollapsedLine()
cy.screenshot('filter-panel-4-editor-collapsed', { capture: 'viewport' })
cy.get('.infoBar-toggle').click()
cy.get('.infoBar').should('have.class', 'expanded')
assertClauseFullyVisible()
assertClauseSpansSeveralLines()
cy.screenshot('filter-panel-4-editor-expanded', { capture: 'viewport' })
})
})
/**
* Cypress clears cookies between tests, which drops the SASjs Server session
* and lands the app back on the evaluation agreement card. Accept it when it is
* shown - a no-op when the session survived.
*/
const bootApp = () => {
cy.visit(hostUrl, { timeout: longerCommandTimeout })
cy.get('body').then(($body: any) => {
if ($body.find('#TCS input[type="checkbox"]').length) {
cy.get('#TCS input[type="checkbox"]').check({ force: true })
cy.wait(4000)
}
})
}
/** Opens a table in the viewer and waits for its grid to render. */
const openTable = (libMem: string) => {
cy.visit(`${hostUrl}${appLocation}/#/view/data/${libMem}`, {
timeout: longerCommandTimeout
})
cy.get('.filterSide', { timeout: longerCommandTimeout }).should('exist')
cy.wait(2500)
}
const openFilterPopup = () => {
cy.get('.filterSide', { timeout: longerCommandTimeout }).click()
cy.get('clr-dropdown-menu', { timeout: longerCommandTimeout })
.contains('Filter')
.click()
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
'be.visible'
)
}
/**
* The soft-select inputs drop a transparent .overlay click-catcher over the
* modal while their suggestion list is open, so they need force.
*/
const setFilterVariable = (column: string) => {
cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true })
cy.get('#datalist_vals_var_id0_0 option').contains(column).click({
force: true
})
cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true })
cy.wait(400)
}
const setFilterValue = (value: string) => {
cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true })
cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true })
cy.wait(400)
}
const setFilterOperator = (operator: string) => {
cy.get('.filter-modal .operator-col select').first().select(operator)
cy.wait(500)
}
/** IN/NOT IN take their values from a modal of checkboxes - take them all. */
const chooseAllFilterValues = () => {
cy.contains('.filter-modal button', 'Choose values').click()
cy.get('.in-values-modal', { timeout: longerCommandTimeout }).should(
'be.visible'
)
cy.get('.in-values-modal input[type=checkbox]').then(($checkboxes: any) => {
for (let i = 0; i < $checkboxes.length; i++) {
cy.get('.in-values-modal input[type=checkbox]')
.eq(i)
.click({ force: true })
}
})
cy.contains('.in-values-modal button', 'Apply').click()
cy.wait(500)
}
const submitFilter = () => {
cy.contains('.filter-modal button', 'Ok').click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
cy.wait(2500)
}
/** The clause is wider than the collapsed line, so it is clipped. */
const assertClauseOverflowsCollapsedLine = () => {
cy.get('.infoBar-text').then(($text: any) => {
const el = $text[0] as HTMLElement
expect(
el.scrollWidth,
'the clause is wider than the collapsed line'
).to.be.greaterThan(el.clientWidth)
expect(
el.ownerDocument.defaultView!.getComputedStyle(el).whiteSpace,
'collapsed panel does not wrap'
).to.equal('nowrap')
})
}
/** The clause fits the collapsed line, so nothing is hidden. */
const assertClauseFitsCollapsedLine = () => {
cy.get('.infoBar-text').then(($text: any) => {
const el = $text[0] as HTMLElement
expect(el.scrollWidth, 'the clause fits the collapsed line').to.be.at.most(
el.clientWidth
)
})
}
/** Expanded, the whole clause is rendered and nothing is clipped. */
const assertClauseFullyVisible = () => {
cy.get('.infoBar-text').then(($text: any) => {
const el = $text[0] as HTMLElement
const style = el.ownerDocument.defaultView!.getComputedStyle(el)
expect(style.whiteSpace, 'expanded panel wraps').to.equal('normal')
expect(el.scrollWidth, 'expanded panel clips nothing').to.be.at.most(
el.clientWidth
)
})
}
/** Expanded, the clause occupies several lines rather than one. */
const assertClauseSpansSeveralLines = () => {
cy.get('.infoBar-text').then(($text: any) => {
const el = $text[0] as HTMLElement
const lineHeight =
parseFloat(
el.ownerDocument.defaultView!.getComputedStyle(el).lineHeight
) || 20
expect(
el.clientHeight / lineHeight,
'the clause spans several lines'
).to.be.greaterThan(3)
})
}
+17 -131
View File
@@ -11,18 +11,6 @@ context('filtering tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
cy.loginAndUpdateValidKey()
/**
* The evaluation agreement is per-origin localStorage, so a fresh browser
* profile lands on it and the nav tree never renders. Guarded, so it is a
* no-op once an earlier spec in the run has accepted it.
*/
cy.get('body').then(($body) => {
if ($body.find('#TCS input[type="checkbox"]').length) {
cy.get('#TCS input[type="checkbox"]').check({ force: true })
cy.wait(4000)
}
})
})
this.beforeEach(() => {
@@ -37,7 +25,7 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
checkInfoBarIncludes(
`(( SOME_CHAR = 'this is dummy data' ))`,
`AND,AND,0,SOME_CHAR,=,"'this is dummy data'"`,
(includes: boolean) => {
if (includes) done()
}
@@ -51,7 +39,7 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_NUM', '42', 'value', () => {
checkInfoBarIncludes(`(( SOME_NUM = 42 ))`, (includes: boolean) => {
checkInfoBarIncludes(`AND,AND,0,SOME_NUM,=,42`, (includes: boolean) => {
if (includes) done()
})
})
@@ -64,10 +52,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_TIME', '00:00:42', 'time', () => {
checkInfoBarIncludes(
`(( SOME_TIME = '00:00:42't ))`,
`AND,AND,0,SOME_TIME,=,42`,
(includes: boolean) => {
if (!includes) return
assertGridRowCount(3, () => done())
if (includes) done()
}
)
})
@@ -80,10 +67,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_TIME', '42', 'value', () => {
checkInfoBarIncludes(
`(( SOME_TIME = '00:00:42't ))`,
`AND,AND,0,SOME_TIME,=,42`,
(includes: boolean) => {
if (!includes) return
assertGridRowCount(3, () => done())
if (includes) done()
}
)
})
@@ -96,10 +82,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_DATE', '12/02/1960', 'date', () => {
checkInfoBarIncludes(
`(( SOME_DATE = '12FEB1960'd ))`,
`AND,AND,0,SOME_DATE,=,42`,
(includes: boolean) => {
if (!includes) return
assertGridRowCount(5, () => done())
if (includes) done()
}
)
})
@@ -112,10 +97,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_DATE', '42', 'value', () => {
checkInfoBarIncludes(
`(( SOME_DATE = '12FEB1960'd ))`,
`AND,AND,0,SOME_DATE,=,42`,
(includes: boolean) => {
if (!includes) return
assertGridRowCount(5, () => done())
if (includes) done()
}
)
})
@@ -132,10 +116,9 @@ context('filtering tests: ', function () {
'datetime',
() => {
checkInfoBarIncludes(
`(( SOME_DATETIME = '01JAN1960:00:00:42'dt ))`,
`AND,AND,0,SOME_DATETIME,=,42`,
(includes: boolean) => {
if (!includes) return
assertGridRowCount(5, () => done())
if (includes) done()
}
)
}
@@ -149,10 +132,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_DATETIME', '42', 'value', () => {
checkInfoBarIncludes(
`(( SOME_DATETIME = '01JAN1960:00:00:42'dt ))`,
`AND,AND,0,SOME_DATETIME,=,42`,
(includes: boolean) => {
if (!includes) return
assertGridRowCount(5, () => done())
if (includes) done()
}
)
})
@@ -165,10 +147,9 @@ context('filtering tests: ', function () {
openFilterPopup(() => {
setFilterWithValue('SOME_DATE', '', 'in', () => {
checkInfoBarIncludes(
`(( SOME_DATE IN ('12FEB1960'd) ))`,
`AND,AND,0,SOME_DATE,IN,(0)`,
(includes: boolean) => {
if (!includes) return
assertGridRowCount(5, () => done())
if (includes) done()
}
)
})
@@ -190,34 +171,6 @@ context('filtering tests: ', function () {
// })
// })
// })
/**
* With the pickers off, a temporal BETWEEN takes both of its values from a
* soft-select over the column's values, so the value is chosen from that list
* rather than typed. The range dropdown binds the formatted label, as the
* single-value dropdown's list does, so the field reads the date and the
* clause composition maps it back to the internal day / second count before
* the literal is built - a display string that reached sasTemporalLiteral as
* a non-number would come back null and be passed through to RAW_VALUE
* unquoted, which mp_filtercheck rejects on a numeric column and aborts the
* save. The test asserts the applied clause and the row count, so it pins
* the mapping end to end.
*/
it('7 | Non picker - filter date field BETWEEN from the value list', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
openFilterPopup(() => {
setTemporalRangeFromValueList('SOME_DATE', '12FEB1960', () => {
checkInfoBarIncludes(
`BETWEEN '12FEB1960'd AND '12FEB1960'd`,
(includes: boolean) => {
if (!includes) return
assertGridRowCount(5, () => done())
}
)
})
}, false)
})
})
const checkInfoBarIncludes = (text: string, callback: any) => {
@@ -228,21 +181,6 @@ const checkInfoBarIncludes = (text: string, callback: any) => {
})
}
/**
* The temporal filter tests assert the applied filter still selects the same
* rows as the internal value did - a readable clause that filtered differently
* would be a worse bug than the one being fixed.
*/
const assertGridRowCount = (expected: number, callback: any) => {
cy.get('#hotTable .ht_master tbody tr', {
timeout: longerCommandTimeout
})
.should('have.length', expected)
.then(() => {
if (callback) callback()
})
}
const openFilterPopup = (
callback?: any,
usePickers: boolean = true,
@@ -373,11 +311,7 @@ const setFilterWithValue = (
})
})
// The IN clause is submitted by the modal footer button above; the
// trailing tab/click sequence below is for the plain value fields and
// must not run here (focus is gone once the modal closes and the
// editor reloads with the stored filter).
return
break
}
case 'between': {
cy.focused().tab()
@@ -408,54 +342,6 @@ const setFilterWithValue = (
if (callback) callback()
}
/**
* Drives a temporal BETWEEN with the pickers off, choosing both values from the
* range soft-select's list rather than typing them - typing bypasses the
* binding this test exists to pin.
*/
const setTemporalRangeFromValueList = (
variableValue: string,
formatted: string,
callback?: any
) => {
cy.wait(600)
cy.focused().type(variableValue)
cy.wait(100)
cy.get('.variable-col .autocomplete-wrapper', { withinSubject: null })
.first()
.trigger('keydown', { key: 'ArrowDown' })
cy.get('.variable-col .autocomplete-wrapper', {
withinSubject: null
}).trigger('keydown', { key: 'Enter' })
cy.focused().tab()
cy.wait(100)
cy.focused().select('BETWEEN').trigger('change')
cy.wait(400)
const chooseFromList = (range: 'start' | 'end') => {
const inputId = `vals-${range}_0_0`
cy.get(`#${inputId}`)
.clear({ force: true })
.type(formatted, { force: true })
cy.get(`#datalist_${inputId} option`)
.contains(formatted)
.click({ force: true })
cy.get(`#${inputId}`).trigger('keyup', { key: 'Escape', force: true })
cy.wait(300)
}
chooseFromList('start')
chooseFromList('end')
cy.contains('.filter-modal button', 'Ok').click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
if (callback) callback()
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
-388
View File
@@ -1,388 +0,0 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see e.g. viewer-labels.cy.ts.
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Fixture: MPE_X_SEARCH, a 1000-row demo table built by the mock data builder
// (sas/mocks/sasjs/services/admin/createdemodata.js). It is deterministic, so every
// count below can be derived by hand:
// - 1000 rows, PK 1001-2000, columns in demo order: PRIMARY_KEY_FIELD,
// SITE_NAME, VESSEL, SAMPLE_COUNT, DEPTH_M, SPECIES, CRUISE_DATE,
// EXPEDITION_ID, NOTES
// - 300 rows have a site name containing "Trench" (case sensitive, so
// "trench" matches nothing)
// - 280 rows contain "Halcyon": the site Halcyon Trench (100 rows) plus the
// vessel RV Halcyon (200 rows) less the 20 rows that are both
// - 3 rows carry "siphonophore" in the middle of their NOTES text, in the
// sites Oceanus Rise, Nereid Trench and Halcyon Trench
// - 100 rows observed Vampire squid (species is picked by (site+vessel)%10)
// - 100 rows are at Oceanus Rise (site is picked by (n%50)/5)
// - 9 rows have an expedition id starting EXP-000 (EXP-0001..EXP-0009)
// - the first row (PK 1001) is the only one with EXPEDITION_ID EXP-0001
// - 12 rows were sounded at exactly 1000m (DEPTH_M = 1000 + (n%90)*37)
// - 2 rows have SAMPLE_COUNT 4210 (the only numeric value that is not a
// primary key, a depth or a date)
// - PK 2000 is the last row of the table
// - 200 rows were surveyed by RV Halcyon, 60 of them at a Trench site
const demoTable = 'mpe_x_search'
// Full-table search: the viewer's search box searches EVERY column of the
// table, character columns by case-sensitive CONTAINS and numeric columns by
// exact equality (see %mp_searchdata in the SASjs macro core, and
// viewdata.sas / services/public/viewdata.js).
//
// The whole walkthrough is one test - the table is opened once and every
// variation is a search in place, which is how a user works and what makes the
// recording read as a demo rather than a set of page loads.
//
// Some searches legitimately match nothing: they pin the case sensitivity, the
// exact-match numeric semantics and the "no data found" handling. Those steps
// are wrapped in negativeStep() and can be skipped when recording the demo, so
// the video only ever shows searches that return rows:
//
// npx cypress run --browser electron \
// --spec cypress/e2e/full-table-search.cy.ts --env skipNegative=true
//
// CI runs the file with no env, so every step runs there.
const skipNegative = `${Cypress.env('skipNegative')}` === 'true'
const negativeStep = (step: () => void) => {
if (!skipNegative) step()
}
context('full table search tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout })
// forceLicenceKey=true: applies a valid licence key instead of relying on
// an earlier spec in the same run having already done so.
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation, { timeout: longerCommandTimeout })
// Visit 'home' first, matching filtering.cy.ts/viewer-labels.cy.ts — going
// straight to 'view/data' skips the startup-data fetch 'home' triggers,
// which leaves the nav tree empty in slower environments.
visitPage('home')
visitPage('view/data')
})
it('searches the whole table from the viewer', () => {
// Full view: every column, and the true row count for the table.
openTableFromTree(libraryToOpenIncludes, demoTable)
assertRowCount('1,000')
assertGridContains(['Nereid Trench', 'RV Pelagia', 'Vampire squid'])
// 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.
searchFor('Halcyon')
assertRowCount('280')
assertGridContains(['Halcyon Trench', 'RV Halcyon'])
// A multi-word site name: the search is not limited to single tokens.
searchFor('Oceanus Rise')
assertRowCount('100')
assertGridContains(['Oceanus Rise'])
// Multi-word values work the same way - species names are two words.
searchFor('Vampire squid')
assertRowCount('100')
assertGridContains(['Vampire squid'])
assertGridExcludes(['Yeti crab'])
// A partial value in a short code column: EXP-000 matches the first nine
// expedition ids.
searchFor('EXP-000')
assertRowCount('9')
assertGridContains(['1001', '1009'])
// A value that is unique in the table: the first row's expedition id.
searchFor('EXP-0001')
assertRowCount('1')
assertGridContains(['1001'])
// Character search is case sensitive: the same word in the wrong case
// matches nothing.
negativeStep(() => {
searchFor('trench')
assertNoData()
})
searchFor('Trench')
assertRowCount('300')
assertGridContains(['Halcyon Trench'])
assertGridExcludes(['Kraken Hollow'])
// Numeric search is an exact match across the numeric columns.
setNumericSearch(true)
searchFor('4210')
assertRowCount('2')
assertGridContains(['1221', '1781', '4210'])
// The numeric search covers every numeric column, not just the primary
// key: 12 rows were sounded at exactly 1000m.
searchFor('1000')
assertRowCount('12')
assertGridContains(['1091'])
// ...and an exact primary key is a single row.
searchFor('2000')
assertRowCount('1')
assertGridContains(['2000'])
negativeStep(() => {
searchFor('421')
assertNoData()
})
// Back to a character search: clearing the box restores the full table.
setNumericSearch(false)
searchFor('')
assertRowCount('1,000')
// A search runs inside the current filter, not the whole table.
openFilterPopup(() => {
setFilterWithValue('VESSEL', 'RV Halcyon', () => {
assertRowCount('200')
checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`)
searchFor('Trench')
assertRowCount('60')
assertGridContains(['Halcyon Trench'])
checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`)
// Clearing the search leaves the filter in place.
searchFor('')
assertRowCount('200')
checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`)
})
})
negativeStep(() => {
searchFor('not-in-this-table')
assertNoData()
})
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// Recording-only linger. A beat's row count is the moment its result is on
// screen, so that is where a demo recording pauses to let it be read. CI runs
// with no env, so demoPauseMs is 0 and the spec contains no waits at all:
//
// npx cypress run --browser electron --spec cypress/e2e/full-table-search.cy.ts \
// --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)".
const assertRowCount = (rows: string) => {
cy.get('.title-col p', { timeout: longerCommandTimeout }).should(
($el: any) => {
const unit = rows === '1' ? 'row' : 'rows'
expect($el.text().replace(/\s+/g, ' ')).to.contain(`(${rows} ${unit},`)
}
)
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"
// panel - and must not raise a request error.
const assertNoData = () => {
cy.get('.noData h3', { timeout: longerCommandTimeout }).should(
'contain.text',
'No data found with given conditions'
)
}
const assertGridContains = (values: string[]) => {
cy.get('#hotTable', { timeout: longerCommandTimeout }).should(
($grid: any) => {
const text = $grid.text().replace(/\s+/g, ' ')
for (const value of values) expect(text).to.contain(value)
}
)
}
const assertGridExcludes = (values: string[]) => {
cy.get('#hotTable', { timeout: longerCommandTimeout }).should(
($grid: any) => {
const text = $grid.text().replace(/\s+/g, ' ')
for (const value of values) expect(text).to.not.contain(value)
}
)
}
const searchFor = (value: string) => {
cy.get('input[name="search-input"]', { timeout: longerCommandTimeout })
.should('be.visible')
.clear()
if (value.length > 0) cy.get('input[name="search-input"]').type(value)
cy.get('input[name="search-input"]').type('{enter}')
}
const setNumericSearch = (enabled: boolean) => {
cy.get('input[name="numeric_check"]', { timeout: longerCommandTimeout }).then(
(checkbox: any) => {
if (checkbox[0].checked !== enabled) checkbox[0].click()
}
)
}
const checkInfoBarIncludes = (text: string) => {
cy.get('.infoBar b', { timeout: longerCommandTimeout }).should(($el: any) => {
expect($el.text().toLowerCase()).to.contain(text.toLowerCase())
})
}
// Opens the viewer's options menu and clicks through to the filter modal.
const openFilterPopup = (callback?: any) => {
cy.get('.filterSide', { timeout: longerCommandTimeout }).first().click()
cy.get('.dropdown-menu', { timeout: longerCommandTimeout })
.contains('Filter')
.click()
cy.get('#vals_var_id0_0', { timeout: longerCommandTimeout }).should('exist')
if (callback) callback()
}
// Fills the first clause of the filter modal (variable, then value) and
// submits it - the viewer stores the clause, reloads the table with the new
// filter RK and shows the generated WHERE text in the info bar.
const setFilterWithValue = (
variableValue: string,
valueString: string,
callback?: any
) => {
cy.get('#vals_var_id0_0').type(variableValue)
cy.get('#datalist_vals_var_id0_0 option')
.contains(variableValue)
.click({ force: true })
// The soft-select closes its suggestion list shortly after a selection -
// wait for that closed state instead of sleeping.
cy.get('#datalist_vals_var_id0_0').should('have.class', 'hidden')
cy.get('#vals_0_0').type(valueString)
closeAutocompleteList('#vals_0_0')
cy.get('#datalist_vals_0_0').should('have.class', 'hidden')
// Live on the markup rather than a label: the filter modal's submit button
// has its text ("Ok") and no explicit aria-label, and the ARIA pass no
// longer synthesises one.
cy.contains('.filter-modal .modal-footer button', /^\s*Ok\s*$/).click()
if (callback) callback()
}
// The soft-select inputs open a suggestion list that drops a transparent
// click-catcher over the modal until it closes, so send the same Escape key a
// user would press. `force` is needed because the input sits underneath that
// click-catcher while the list is open.
const closeAutocompleteList = (inputSelector: string) => {
cy.get(inputSelector).trigger('keyup', { key: 'Escape', force: true })
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
// Selecting the table triggers async SPA routing + a viewdata fetch; wait
// for the grid to actually render before any subsequent action.
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
-209
View File
@@ -1,209 +0,0 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files.
//
// RUN ORDER MATTERS, and build.yaml reflects it: this spec must run after
// csv-limited.cy.ts (which asserts the free tier and so needs the first slot,
// before any spec applies a licence key) and before every spec that submits a
// changeset. Test 2 reads the approval queue, which is paged oldest first, and
// a changeset submitted by an earlier spec pushes this one off the first page.
export {}
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('hook program tests: ', function () {
this.beforeAll(() => {
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
// TESTDATA.DEMO_MIRROR is an empty mirror of TESTDATA.DEMO_ORDERS, registered
// with a PRE_EDIT_HOOK and a POST_EDIT_HOOK (services/hooks/demo_mirror_*.js).
// The mock services run those hooks the way the SAS backend %includes the
// real hook programs - see dcMockUtils.mockHookSource.
it('1 | PRE_EDIT_HOOK: an empty mirror displays the live rows of its target table', () => {
openTableFromTree('testdata', 'demo_mirror')
// The mirror holds no rows of its own: everything on screen comes from the
// pre-edit hook, which loads TESTDATA.DEMO_ORDERS.
cy.get('#hotTable .ht_master tbody tr', {
timeout: longerCommandTimeout
}).should('have.length', 4)
cy.get('#hotTable .ht_master tbody tr')
.first()
.should('contain.text', 'Acme Corp')
cy.get('#hotTable .ht_master tbody tr')
.last()
.should('contain.text', 'Delta Systems')
// The grid is validated against the MIRROR's own rule set (AMOUNT <= 2000),
// not the real table's (AMOUNT <= 100000) - that is the point of routing
// edits through a mirror.
clickOnEdit(() => {
editCell(0, 'AMOUNT', '5000')
})
cy.get('#hotTable .ht_master tbody tr')
.first()
.find('td.htInvalid')
.should('exist')
})
it('2 | POST_EDIT_HOOK: a change submitted against the mirror is raised against the target', () => {
openTableFromTree('testdata', 'demo_mirror')
clickOnEdit(() => {
editCell(0, 'AMOUNT', '750')
})
submitTable(() => {
cy.get('textarea.submit-reason', { timeout: longerCommandTimeout }).type(
'hook routing test'
)
submitTableMessage()
})
// A successful submit sends the app to the staged-data page; wait for that
// before navigating away, or the app's own redirect wins the race.
cy.url({ timeout: longerCommandTimeout }).should('include', '/stage/')
// The post-edit hook re-points the changeset at TESTDATA.DEMO_ORDERS, so the
// approval queue names the real table rather than the mirror. Search the
// whole grid rather than one row: the queue also holds changesets from
// earlier specs in the same run, so position is not a stable anchor.
visitPage('review/approve')
cy.get('clr-datagrid clr-dg-row', { timeout: longerCommandTimeout }).should(
($rows) => {
const texts = Array.from($rows).map((row: any) =>
row.innerText.trim().replace(/\s+/g, ' ')
)
expect(
texts.some((text) => text.includes('TESTDATA.DEMO_ORDERS')),
`no approval row names TESTDATA.DEMO_ORDERS - rows: ${texts
.slice(0, 5)
.join(' | ')
.slice(0, 400)}`
).to.be.true
}
)
})
})
// ─── helpers (declared at the bottom, per the other specs) ───────────────────
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.wait(300)
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
// Edits a single cell, locating the column by its header label. Handsontable
// renders the frozen header in a separate clone pane (.ht_clone_top) - the
// header row inside .ht_master is kept visibility:hidden - so the labels are
// read from the clone.
const editCell = (rowIndex: number, colName: string, value: string) => {
cy.get('#hotTable .ht_clone_top .htCore thead th', {
timeout: longerCommandTimeout
}).then((headers: any) => {
let colIndex = -1
for (let i = 0; i < headers.length; i++) {
if (
String(headers[i].innerText || '')
.trim()
.toUpperCase() === colName.toUpperCase()
) {
colIndex = i
break
}
}
expect(colIndex, `column ${colName} present`).to.be.greaterThan(-1)
cy.get('#hotTable .ht_master tbody tr')
.eq(rowIndex)
.then((row: any) => {
cy.get(row[0].childNodes[colIndex])
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type(value + '{enter}')
})
})
})
}
const submitTable = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
const submitTableMessage = (callback?: any) => {
cy.get('.modal-footer .btn.btn-sm.btn-success-outline', {
timeout: longerCommandTimeout
})
.click()
.then(() => {
if (callback) callback()
})
}
+194 -180
View File
@@ -45,13 +45,21 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => {
keys = keysGen
gotoLicensingUpdate().then(() => {
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
})
cy.then(() => {
cy.wait(2000)
isLicensingPage((result: boolean) => {
if (result) {
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
cy.wait(2000)
}
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(10000)
}
visitPage('home')
waitForAppSettled()
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
@@ -78,9 +86,18 @@ context('licensing tests: ', function () {
keys = keysGen
console.log('keys', keys)
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
verifyLicensingWarning('This license key will expire in ', () => {
done()
})
@@ -103,18 +120,32 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => {
keys = keysGen
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
verifyLicensingWarning('(FREE Tier) - Problem with licence', () => {
openLicenceProblemDetails()
verifyLicensingPage(
'Licence key is expired - please contact',
(success: boolean) => {
if (success) done()
if (result) {
cy.wait(20000)
}
verifyLicensingPage(
'Licence key is expired - please contact',
(success: boolean) => {
if (success) {
verifyLicensingWarning(
'(FREE Tier) - Problem with licence',
() => {
done()
}
)
}
)
})
}
)
})
})
})
@@ -135,18 +166,32 @@ context('licensing tests: ', function () {
keys = keysGen
keys.activationKey = 'invalid' + keys.activationKey
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
verifyLicensingWarning('(FREE Tier) - Problem with licence', () => {
openLicenceProblemDetails()
verifyLicensingPage(
'Licence key is invalid - please contact',
(success: boolean) => {
if (success) done()
if (result) {
cy.wait(20000)
}
verifyLicensingPage(
'Licence key is invalid - please contact',
(success: boolean) => {
if (success) {
verifyLicensingWarning(
'(FREE Tier) - Problem with licence',
() => {
done()
}
)
}
)
})
}
)
})
})
})
@@ -167,18 +212,32 @@ context('licensing tests: ', function () {
keys = keysGen
keys.activationKey = keys.activationKey
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
verifyLicensingWarning('(FREE Tier) - Problem with licence', () => {
openLicenceProblemDetails()
verifyLicensingPage(
'SYSSITE (below) is not found',
(success: boolean) => {
if (success) done()
if (result) {
cy.wait(20000)
}
verifyLicensingPage(
'SYSSITE (below) is not found',
(success: boolean) => {
if (success) {
verifyLicensingWarning(
'(FREE Tier) - Problem with licence',
() => {
done()
}
)
}
)
})
}
)
})
})
})
@@ -188,9 +247,7 @@ context('licensing tests: ', function () {
it('6 | pasting an HTTP-format key (identical licence/activation text) on this secure connection warns before applying', (done) => {
visitPage('licensing/update')
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - switch to the legacy
// two-field layout, since this test targets that path specifically.
@@ -230,17 +287,25 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - earlier tests may have left the app on it (a
// bad key) or already activated (a good one), and this test should
// pass either way rather than silently no-op when it's the latter.
cy.then(() => {
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
inputCombinedKeyPage(combinedKey)
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
@@ -252,16 +317,17 @@ context('licensing tests: ', function () {
}
)
gotoLicensingUpdate()
// proceed() generates the combined key asynchronously, then
// enqueues the actual page commands only once its promise
// resolves. Bridged through cy.then() so the command chain stays
// open across that async gap: a bare proceed() call returns a
// promise that nothing awaits, and once the surrounding queue
// has drained, commands enqueued from that promise's continuation
// are never run.
cy.then(() => proceed())
if (!result) {
visitPage('licensing/update')
// Chained via .then() rather than called as the next plain
// statement - generateCombinedKey is a raw async function that
// itself invokes cy commands (cy.log), and Cypress can't
// reconcile that promise with the preceding cy.visit()/cy.wait()
// unless it's explicitly handed off through a real command chain.
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
@@ -276,15 +342,15 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
cy.then(() => {
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
cy.get('button')
.contains('Paste as two separate keys instead')
.click()
@@ -307,7 +373,13 @@ context('licensing tests: ', function () {
cy.get('button.apply-keys').click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
@@ -319,9 +391,14 @@ context('licensing tests: ', function () {
}
)
// See test 7's own comment for why proceed() is bridged through
// cy.then() rather than called as a plain statement.
gotoLicensingUpdate().then(() => proceed())
// See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement.
if (!result) {
visitPage('licensing/update')
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
@@ -336,9 +413,11 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
cy.then(() => {
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
@@ -352,7 +431,13 @@ context('licensing tests: ', function () {
timeout: longerCommandTimeout
}).click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
@@ -364,9 +449,14 @@ context('licensing tests: ', function () {
}
)
// See test 7's own comment for why proceed() is bridged through
// cy.then() rather than called as a plain statement.
gotoLicensingUpdate().then(() => proceed())
// See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement.
if (!result) {
visitPage('licensing/update')
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
@@ -386,15 +476,15 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
cy.then(() => {
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
cy.get('.combined-key-form textarea', {
timeout: longerCommandTimeout
@@ -432,45 +522,16 @@ context('licensing tests: ', function () {
// See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement.
gotoLicensingUpdate().then(() => proceed())
if (!result) {
visitPage('licensing/update')
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
// The registration page belongs to an install that is not registered yet. An
// activated one that reaches it by URL has nothing to register, and accepting
// the EULA would post public/registeruser, which aborts for an
// already-registered user, so it is sent home instead of showing the EULA.
it('11 | an activated install is sent home from the registration page', (done) => {
visitPage('licensing/register')
cy.location('hash', { timeout: longerCommandTimeout })
.should('not.include', 'register')
.then(() => {
cy.get('.nav-tree', { timeout: longerCommandTimeout })
.should('exist')
.then(() => {
done()
})
})
})
// Only the actions in LicenseActions have a page. An unknown segment would
// render the licensing component with no action at all, so it goes home.
it('12 | an unknown licensing action is sent home', (done) => {
visitPage('licensing/this-action-does-not-exist')
cy.location('hash', { timeout: longerCommandTimeout })
.should('not.include', 'licensing')
.then(() => {
cy.get('.nav-tree', { timeout: longerCommandTimeout })
.should('exist')
.then(() => {
done()
})
})
})
if (testLicenceUserLimits) {
it('4 | User try to register when limit is reached', (done) => {
let keyData = {
@@ -489,10 +550,22 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
generateKeys(keyData2, (keysGen2: any) => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen2, () => {
cy.wait(2000)
const random = Cypress._.random(0, 1000)
const newUser = {
username: `randomusername${random}notregistered`,
@@ -509,15 +582,8 @@ context('licensing tests: ', function () {
// cy.get('.login-group button').click()
visitPage('home')
waitForAppSettled()
cy.get('#demo-banner', {
timeout: longerCommandTimeout
})
.should('contain', 'reached the limit')
.within(() => {
cy.get('a.update-key').click()
})
cy.wait(2000)
verifyLicensingPage(
'The registered number of users reached the limit specified for your licence.',
@@ -554,9 +620,17 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => {
generateKeys(keyData2, (keysGen2: any) => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
const random = Cypress._.random(0, 1000)
const newUser = {
username: `randomusername${random}`,
@@ -567,6 +641,7 @@ context('licensing tests: ', function () {
{ deleteAll: true, keep: username, newUsers: [newUser] },
() => {
updateLicenseKeyQuick(keysGen2, () => {
cy.wait(2000)
verifyLicensingWarning(
'The registered number of users exceeds the limit specified for your license.',
() => {
@@ -585,43 +660,6 @@ context('licensing tests: ', function () {
}
})
// A key change re-bootstraps the licence state: .app-loading while the startup
// data loads, then one of these states. Every page these specs act on renders
// one of them, so waiting for the overlay to clear and for one of these
// selectors is a sufficient settled state, and it costs nothing when the app is
// already there. .card-header is the licensing card, which is the only marker
// on the key/update/limit pages when there is no error to show and the install
// has no nav tree or banner yet.
const waitForAppSettled = () => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
cy.get('.nav-tree, #demo-banner, .card-header, .key-error, input.username', {
timeout: longerCommandTimeout
}).should('exist')
}
// The paste form exists only on licensing/update. Asking "are we anywhere
// under licensing/" also matches the error page the app shows when the current
// key is bad, which has no paste button at all. Returns the chainable so a
// caller's next commands are queued after the conditional visit - commands
// queued behind a bare call would run before the deferred navigation.
const gotoLicensingUpdate = () => {
waitForAppSettled()
return cy.location('hash').then((hash: string) => {
if (!hash.includes('licensing/update')) visitPage('licensing/update')
})
}
// The banner is the entry point to the licensing page: it names the licence
// problem and its action link opens the page that explains it.
const openLicenceProblemDetails = () => {
// The banner re-renders when the licence state arrives, so wait for the page
// to settle before clicking its link.
waitForAppSettled()
cy.get('#demo-banner a.update-key', { timeout: longerCommandTimeout })
.should('contain', 'More details')
.click()
}
const logout = (callback?: any) => {
cy.get('.header-actions .dropdown-toggle')
.click()
@@ -643,17 +681,6 @@ const acceptTermsIfPresented = (callback?: any) => {
.then(() => {
cy.get('#checkbox1')
.click()
.then(() => {
// Accepting the EULA posts public/registeruser, and the app
// reloads once it succeeds. The card is gone after that reload,
// so waiting for it covers the reload; a registration that is
// refused leaves the card in place and fails here.
cy.get('#checkbox1', {
timeout: longerCommandTimeout
}).should('not.exist')
waitForAppSettled()
})
.then(() => {
if (callback) callback(true)
})
@@ -673,26 +700,25 @@ const isLicensingPage = (callback: any) => {
}
const verifyLicensingPage = (text: string, callback: any) => {
// A bad key makes the app redirect to the licensing page. Asserting the URL
// and the error text together means a redirect that never comes fails here,
// rather than being read as a page that simply has no error.
cy.location('hash', { timeout: longerCommandTimeout }).should(
'match',
/licensing\/(?!register)/
)
// visitPage('home')
cy.get('.key-error', { timeout: longerCommandTimeout })
.should('contain', text)
.then(() => {
callback(true)
})
cy.wait(1000)
isLicensingPage((result: boolean) => {
if (result) {
cy.get('.key-error')
.should('contain', text)
.then((treeNodes: any) => {
callback(true)
})
}
})
}
const verifyLicensingWarning = (text: string, callback: any) => {
visitPage('home')
waitForAppSettled()
cy.get("div[role='alert'] .alert-text", { timeout: longerCommandTimeout })
cy.wait(1000)
cy.get("div[role='alert'] .alert-text")
.invoke('text')
.should('contain', text)
.then(() => {
@@ -701,9 +727,7 @@ const verifyLicensingWarning = (text: string, callback: any) => {
}
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - this helper exercises the
// two-part path specifically, so switch to it first.
@@ -718,13 +742,10 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
.trigger('input')
.should('not.be.undefined')
cy.get('button.apply-keys').click()
waitForAppSettled()
}
const inputCombinedKeyPage = (combinedKey: string) => {
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
// Combined is the default paste format - no toggle click needed here,
// unlike inputLicenseKeyPage's legacy two-field path.
@@ -749,7 +770,6 @@ const inputCombinedKeyPage = (combinedKey: string) => {
const updateUsersTable = (options: any, callback?: any) => {
visitPage('home')
waitForAppSettled()
openTableFromTree(libraryToOpenIncludes, 'mpe_users')
clickOnEdit(() => {
@@ -795,6 +815,7 @@ const updateUsersTable = (options: any, callback?: any) => {
// .type('{enter}')
// })
submitTable(() => {
cy.wait(2000)
approveTable(callback)
})
})
@@ -817,6 +838,7 @@ const changeLicenseKeyTable = (keys: any, callback?: any) => {
],
() => {
submitTable(() => {
cy.wait(2000)
approveTable(() => {
cy.reload()
if (callback) callback()
@@ -831,18 +853,10 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
isLicensingPage((result: boolean) => {
if (!result) {
visitPage('licensing/update')
cy.wait(2000)
}
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
// Applying keys navigates to '/' and reloads the app once the service
// accepts them, so the caller continues against the rebooted app rather
// than racing the reload.
cy.location('hash', { timeout: longerCommandTimeout }).should(
'not.include',
'licensing'
)
waitForAppSettled()
callback()
})
}
+36 -45
View File
@@ -1,51 +1,35 @@
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
const fixturePath = 'excels_general/'
context('metanav tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
// beforeAll has already logged in. Typing into the login form here failed
// once the session existed - the form is not on screen, so Cypress reported
// "the center of this element is hidden from view" and the whole suite was
// skipped behind that hook.
cy.visit(hostUrl + appLocation)
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
visitPage('view/metadata')
})
it('1 | Opens metadata object', () => {
openFirstMetadataType()
/**
* Query the accordion at document level. The panel renders in the content
* area, while `.within(<tree node>)` scopes every following `cy.get` to
* that node alone - which finds nothing however long it retries.
*/
cy.get('.clr-accordion-header-button', { timeout: longerCommandTimeout })
.should('have.length.greaterThan', 0)
.first()
// Clarity puts the class on the button itself, so it is the click target.
.click()
// The panel reports its own open state, so the assertion is on the app's
// marker rather than on the content happening to be present.
cy.get('.clr-accordion-panel-open').should('have.length', 1)
cy.get('.clr-accordion-header-button')
.first()
.should('have.attr', 'aria-expanded', 'true')
// Opening the panel loads the object's details: the association tree and
// the attribute grid both come from `metanav/metadetails`.
cy.get('.clr-accordion-inner-content clr-tree-node', {
timeout: longerCommandTimeout
}).should('have.length.greaterThan', 0)
cy.get('clr-dg-row').should('have.length.greaterThan', 0)
cy.get('clr-dg-cell').contains('Name')
it('1 | Opens metadata object', (done) => {
openFirstMetadataFromTree(() => {
// BLOCKER
// For unkown reasons, .clr-accordion-header-button always null although it is present on the page.
cy.get('.clr-accordion-header-button').then((panelNodes: any) => {
panelNodes[0].querySelector('button').click()
})
})
})
this.afterEach(() => {
@@ -53,16 +37,23 @@ context('metanav tests: ', function () {
})
})
const openFirstMetadataType = () => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
// The metadata types are the tree nodes after the search box; the search box
// is its own node (`.search-node`), so selecting by position would tie the
// spec to that detail. The <p> is the element that owns the click handler.
cy.get('.nav-tree clr-tree > clr-tree-node:not(.search-node) p', {
timeout: longerCommandTimeout
})
.first()
.click()
const openFirstMetadataFromTree = (callback?: any) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let firstMetaNode
firstMetaNode = treeNodes[1]
cy.get(firstMetaNode).within(() => {
cy.get('.clr-treenode-content').click()
callback()
})
})
})
}
const visitPage = (url: string) => {
-446
View File
@@ -1,446 +0,0 @@
// The EDIT screen is bounded by DC_MAXOBS_WEBEDIT rows and DC_MAXCELLS_WEBEDIT
// cells (rows x columns), whichever is reached first, and the VIEW screen caps
// at DC_MAXOBS_WEBVIEW rows.
//
// A selection over an EDIT limit is served TRUNCATED - the grid carries the
// first rows that fit, and a warning triangle against the table name says why.
// This spec pins those limits against the mock, which mirrors the shipped
// defaults (250 rows / 200000 cells for EDIT, 2000 rows for VIEW). It uses
// two existing fixtures rather than new ones:
// MPE_X_SEARCH - 1000 rows x 9 cols, so it trips the ROW limit
// MPE_X_WIDE - 200 rows x 1001 cols (200,200 cells), under the row limit
// but over the CELL limit, so the row cap is 199
export {}
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
context('EDIT row and cell limits, VIEW row limit: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
// forceLicenceKey=true: applies a valid licence rather than relying on an
// earlier spec in the run having done so (see commands.ts).
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
visitPage('view/data')
})
it('1 | VIEW renders up to the raised row cap, past the old 500', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
// Neither the header nor the cell text can pin the cap: the header reports
// the table's true row count whether or not the grid is capped, and the
// fixture's own numeric columns carry values above 1500 (DEPTH_M reaches
// 4293, SAMPLE_COUNT 4210), so a text match can hit fixture data.
//
// The PK can pin it. The first td of a .ht_master row is the PK - the row
// header, where present, is a th - and MPE_X_SEARCH holds PK 1001-2000, so
// the range check below is what stops a mis-selected column passing. With
// the old 500 row cap the grid stopped at PK 1500, so a rendered PK above
// 1500 can only appear if the cap was raised.
scrollGridToBottom()
cy.get('#hotTable .ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should(($rows) => {
const pks = $rows
.toArray()
.map((row) => row.querySelectorAll('td')[0])
.filter((td) => !!td)
.map((td) => parseInt((td.textContent || '').trim(), 10))
.filter((pk) => !isNaN(pk))
expect(pks.length).to.be.greaterThan(0)
pks.forEach((pk) => expect(pk).to.be.within(1001, 2000))
expect(Math.max(...pks)).to.be.greaterThan(1500)
})
})
it('2 | EDIT serves a table over the row limit, truncated, with a warning', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
openEdit()
// 1000 rows x 9 columns is over the 250 row limit, so the grid carries the
// first 250 rows and the header says how many it is showing.
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'250 rows'
)
cy.get('#hotTable .ht_master .htCore tbody tr', {
timeout: longerCommandTimeout
}).should('have.length.at.least', 1)
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
.should('be.visible')
.realHover()
cy.get('.editor-title clr-tooltip-content', {
timeout: longerCommandTimeout
})
.should('be.visible')
.should('contain.text', 'DC_MAXOBS_WEBEDIT')
.should(($el) => expect($el.css('opacity')).to.eq('1'))
})
it('3 | EDIT serves a table over the cell limit, truncated, with a warning', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_wide')
openEdit()
// 200 rows x 1001 stored columns = 200,200 cells, over the 200,000 cell
// limit, while the row count is under the 250 row limit - so the cell
// limit sets the cap: floor(200000 / 1001) = 199 rows.
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'199 rows'
)
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
.should('be.visible')
.realHover()
cy.get('.editor-title clr-tooltip-content', {
timeout: longerCommandTimeout
})
.should('be.visible')
.should('contain.text', 'DC_MAXCELLS_WEBEDIT')
.should(($el) => expect($el.css('opacity')).to.eq('1'))
})
it('4 | the EDIT limits follow MPE_CONFIG, not the shipped defaults', () => {
// Two things at once. The seeded config carries the shipped defaults (250
// rows / 200000 cells), which are the numbers the mock used to hard-code -
// so nothing above would notice if the mock stopped reading MPE_CONFIG at
// all. And MPE_CONFIG lives in the control library, so reading it from the
// table's own library would fail for this table in particular.
//
// The cell limit is the one to move: 7 rows x 17 columns is 119 cells, so
// at 110 the TESTDATA.MPE_X_NEW edit must be truncated to
// floor(110 / 17) = 6 rows, where a hard-coded 200000 - or a config read
// that fails and falls back to it - lets it through. 110 is still above
// MPE_CONFIG's own 6 x 17 = 102, so the option can be put back afterwards.
setConfigValue('DC_MAXCELLS_WEBEDIT', '110', () => {
visitPage('view/data/TESTDATA.MPE_X_NEW')
closeAbortModalIfPresent()
openEdit()
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'6 rows'
)
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
.should('be.visible')
.realHover()
cy.get('.editor-title clr-tooltip-content', {
timeout: longerCommandTimeout
})
.should('be.visible')
.should('contain.text', 'DC_MAXCELLS_WEBEDIT')
.should(($el) => expect($el.css('opacity')).to.eq('1'))
})
// Put the option back, so the rest of the estate sees the shipped default.
setConfigValue('DC_MAXCELLS_WEBEDIT', '200000', () => {
visitPage('view/data/TESTDATA.MPE_X_NEW')
closeAbortModalIfPresent()
openEdit()
// 119 cells under a 200000 cell limit: all 7 rows load, no warning.
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'7 rows'
)
cy.get('.truncation-warning').should('not.exist')
})
})
it('5 | the warning says to filter, and filtering narrows the grid', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
openEdit()
cy.get('.truncation-warning', { timeout: longerCommandTimeout })
.should('be.visible')
.realHover()
cy.get('.editor-title clr-tooltip-content', {
timeout: longerCommandTimeout
})
.should('be.visible')
.should('contain.text', 'Filter the table to work with the rest')
.should(($el) => expect($el.css('opacity')).to.eq('1'))
// The advice has to be actionable: the picker must offer the table's
// columns and the filter must reload the grid.
openFilterPopup()
filterVariables().should(($options) => {
const names = $options
.toArray()
.map((o) => (o.textContent || '').trim())
.filter((n) => n.length > 0)
expect(names, 'the picker offers the table columns').to.include.members([
'PRIMARY_KEY_FIELD',
'SITE_NAME',
'VESSEL',
'SAMPLE_COUNT',
'DEPTH_M',
'SPECIES',
'CRUISE_DATE',
'EXPEDITION_ID',
'NOTES'
])
})
setFilterVariable('SITE_NAME')
// 100 of the 1000 rows: under the limit, so nothing is truncated.
setFilterValue('Nereid Trench')
submitFilter()
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
"SITE_NAME = 'Nereid Trench'"
)
cy.get('.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
'100 rows'
)
cy.get('.truncation-warning').should('not.exist')
})
})
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
// The viewer's options dropdown holds the Edit item; it only renders for a
// table that is registered in MPE_TABLES (tableEditExists()).
const openEdit = () => {
cy.get('.filterSide', { timeout: longerCommandTimeout }).click()
cy.contains('[clrDropdownItem]', 'Edit', {
timeout: longerCommandTimeout
}).click()
}
/**
* The EDIT screen's Filter button opens the same .filter-modal the viewer uses.
* It only renders once the editor is in its read-only (non-editing) state,
* which is where a failed EDIT load leaves it.
*/
const openFilterPopup = () => {
cy.contains('button.btnView', 'Filter', {
timeout: longerCommandTimeout
}).click()
cy.get('.filter-modal', { timeout: longerCommandTimeout }).should(
'be.visible'
)
}
/** The picker's variable suggestion list. */
const filterVariables = () =>
cy.get('#datalist_vals_var_id0_0 option', { timeout: longerCommandTimeout })
/** The soft-select drops a transparent overlay over the modal, so it needs force. */
const setFilterVariable = (column: string) => {
cy.get('#vals_var_id0_0').clear({ force: true }).type(column, { force: true })
cy.get('#datalist_vals_var_id0_0 option').contains(column).click({
force: true
})
cy.get('#vals_var_id0_0').trigger('keyup', { key: 'Escape', force: true })
}
/** The value input of the same soft-select row. */
const setFilterValue = (value: string) => {
cy.get('#vals_0_0').clear({ force: true }).type(value, { force: true })
cy.get('#vals_0_0').trigger('keyup', { key: 'Escape', force: true })
cy.wait(400)
}
const submitFilter = () => {
cy.contains('.filter-modal button', 'Ok').click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
cy.wait(2500)
}
/**
* Sets a DC option in MPE_CONFIG through the editor and approves it, then hands
* back to the callback. The same route licensing.cy.ts uses for the licence
* keys: the config table needs an approval like any other.
*/
const setConfigValue = (
varName: string,
varValue: string,
callback: () => void
) => {
reloadApp()
// straight to the config table's editor, so the flow does not depend on
// whichever view mode the previous step left behind
visitPage('editor/DC_JSLIB.MPE_CONFIG')
closeAbortModalIfPresent()
clickOnEdit()
editTableField([{ varName, varValue }], () => {
submitTable(() => {
cy.wait(2000)
approveTable(() => {
reloadApp()
callback()
})
})
})
}
/**
* A full page load. A visit that only changes the hash does not reload the
* app, so an open modal - the abort message from the step just gone, say -
* would still be covering the page.
*/
const reloadApp = () => {
cy.visit(hostUrl + appLocation)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
}
/**
* DC puts abort messages up as modals over whatever page is showing, and they
* outlive a hash-only navigation, so clear one before driving the page.
*/
const closeAbortModalIfPresent = () => {
cy.get('body').then(($body) => {
if ($body.find('app-info-modal').length > 0) {
cy.get('app-info-modal')
.contains('button', 'Close')
.click({ force: true })
}
})
}
/**
* The viewer's Edit button. The options dropdown (.filterSide) that openEdit()
* uses is not rendered for every table, so the config table is opened the way
* licensing.cy.ts opens it.
*/
const clickOnEdit = (callback?: () => void) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
/** Types a new value into the row whose cell carries varName. */
const editTableField = (
edits: { varName: string; varValue: string }[],
callback: () => void
) => {
cy.get('#hotTable .ht_master .htCore tbody td', {
timeout: longerCommandTimeout
}).then(($tds: any) => {
const nodes = $tds.toArray()
for (const edit of edits) {
for (let i = 0; i < nodes.length; i++) {
// exact match on the cell that holds the option name; the cell after it
// is the value
if ((nodes[i].textContent || '').trim() === edit.varName) {
cy.wrap(nodes[i + 1])
.dblclick()
.then(() => {
cy.focused()
.type('{selectall}')
.type(edit.varValue)
.type('{enter}')
})
break
}
}
}
callback()
})
}
const submitTable = (callback: () => void) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
cy.get(".modal.ng-star-inserted button[type='submit']").click()
callback()
})
}
const approveTable = (callback: () => void) => {
cy.get('button', { timeout: longerCommandTimeout })
.should('contain', 'Approve')
.then((allButtons: any) => {
for (const approvalButton of allButtons) {
if (approvalButton.innerText.toLowerCase().includes('approve')) {
approvalButton.click()
break
}
}
cy.get('button#acceptBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.click()
.then(() => {
cy.get('app-history', { timeout: longerCommandTimeout }).should(
'exist'
)
callback()
})
})
}
// Handsontable virtualises rows, so the last row is only in the DOM once the
// grid has been scrolled to the bottom. The scroller is the .wtHolder inside
// the .ht_master pane.
const scrollGridToBottom = () => {
cy.get('#hotTable .ht_master .wtHolder', { timeout: longerCommandTimeout })
.scrollTo('bottom', { duration: 0 })
.trigger('scroll')
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let targetLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
targetLib = node
break
}
}
cy.get(targetLib).within(() => {
// Small settle wait: right after a fresh visit the tree component can
// still be re-rendering, causing this node to be found then swapped
// out mid-click.
cy.wait(300)
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
+14 -120
View File
@@ -1,81 +1,37 @@
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
const fixturePath = 'excels/'
context('stage tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
visitPage('stage/DC20221007T122326121_612316_7259')
})
// getstagetable's mock ignores the table_id param entirely, so any id in
// the URL resolves to the same fixture row - no need to submit a real
// table first just to reach this page.
it('1 | Formatted/Unformatted toggle switches between fmt_stagetable and stagetable, defaulting to formatted', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
attachExcelFile('regular_excel.xlsx', () => {
submitExcel()
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
// The app navigates to /stage/{tableId}.
cy.url({ timeout: longerCommandTimeout }).should('include', '/stage/')
cy.get('.formatted-values-toggle').click()
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '42')
// Toggle to unformatted first to read the raw SOME_DATE value.
cy.get('.formatted-values-toggle').click()
// Toggling back reverts to the formatted view.
cy.get('.formatted-values-toggle').click()
getCellByHeaderAndRow(0, 'SOME_DATE')
.invoke('text')
.then((rawText) => {
const rawDate = parseInt(rawText, 10)
// Toggle back to formatted view.
cy.get('.formatted-values-toggle').click()
// Compute the expected SAS date9. format from the raw value.
const sasEpoch = new Date(Date.UTC(1960, 0, 1))
const formatted = new Date(sasEpoch.getTime() + rawDate * 86400000)
const months = [
'JAN',
'FEB',
'MAR',
'APR',
'MAY',
'JUN',
'JUL',
'AUG',
'SEP',
'OCT',
'NOV',
'DEC'
]
const expected =
String(formatted.getUTCDate()).padStart(2, '0') +
months[formatted.getUTCMonth()] +
formatted.getUTCFullYear()
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', expected)
// Toggle to unformatted again to verify round-trip.
cy.get('.formatted-values-toggle').click()
getCellByHeaderAndRow(0, 'SOME_DATE').should(
'have.text',
String(rawDate)
)
})
})
getCellByHeaderAndRow(0, 'SOME_DATE').should('have.text', '12FEB1960')
})
})
@@ -83,68 +39,6 @@ const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.get('.clr-tree-node-content-container > button').click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
}
const attachExcelFile = (excelFilename: string, callback?: any) => {
cy.get('.buttonBar button:last-child')
.should('exist')
.click()
.then(() => {
cy.get('input[type="file"]#file-upload')
.attachFile(`/${fixturePath}/${excelFilename}`)
.then(() => {
cy.get('.clr-abort-modal .modal-title').then((modalTitle) => {
if (!modalTitle[0].innerHTML.includes('Abort Message')) {
cy.get('.modal-footer .btn.btn-primary').then((modalBtn) => {
modalBtn.click()
if (callback) callback()
})
} else {
if (callback) callback()
}
})
})
})
}
const submitExcel = (callback?: any) => {
cy.get('.buttonBar button.preview-submit', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
// Locates a body cell by its column's header text rather than a hardcoded
// childNodes index - same helper as editor.cy.ts's own.
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
@@ -1,81 +0,0 @@
// Marks this file as an ES module so its top-level consts don't collide with
// other spec files under the TS type-checker.
export {}
//
// When the startup service fails without a usable response, the error modal
// offers a button that runs the same service in a new browser window, where the
// SAS log is returned instead. This spec forces a startup failure against the
// mock backend and checks the button and the URL it opens.
const hostUrl = Cypress.env('hosturl')
const STARTUP_PROGRAM = '/Public/app/dc/services/public/startupservice'
/** The body a Viya Compute task returns when the run outlives the
* execution-task timeout: a bare string, so the adapter has no webout and no
* log to report. */
const rawJobError = [
'Job error',
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context is not reusable.',
'path: /compute/tasks',
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
].join('\n')
context('startupservice launcher: ', function () {
it('1 | runs the startupservice in a new window when its response is malformed', () => {
// Report an empty admin folder so the app loads the startup service: on a
// freshly seeded mock estate makedata is still present, which sends the app
// to the deploy screen instead.
cy.intercept('GET', '**/drive/folder**', (req) => {
// Guarded parse: a lone '%' in the URL would make decodeURIComponent
// throw, and an exception here aborts the request.
let url = req.url
try {
url = decodeURIComponent(req.url)
} catch (e) {
url = req.url
}
if (url.includes('services/admin')) {
req.reply({ files: [], folders: [] })
return
}
req.continue()
})
cy.intercept('**/stp/execute/**', (req) => {
if (req.url.includes('public/startupservice')) {
// The adapter cannot use a bare string as a response payload, so the
// startup request fails with nothing to show.
req.reply({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(rawJobError)
})
return
}
req.continue()
})
// The launcher must not actually open a tab under test.
cy.visit(hostUrl, {
timeout: 60000,
onBeforeLoad(win) {
cy.stub(win, 'open').as('windowOpen')
}
})
cy.get('.clr-abort-modal', { timeout: 60000 }).should('be.visible')
cy.get('.clr-abort-modal').last().should('contain.text', 'startupservice')
cy.get('.clr-abort-modal').last().contains('button', 'Show log').click()
cy.get('@windowOpen').should('have.been.calledOnce')
cy.get('@windowOpen').then((stub: any) => {
const url = stub.firstCall.args[0]
expect(url).to.contain(STARTUP_PROGRAM)
expect(url).to.contain('_debug=131')
// The browser route is what makes the log available: no execution task.
expect(url).not.to.contain('_executionTasks')
})
})
})
@@ -1,73 +0,0 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files — see e.g. stage.cy.ts, which gets
// this for free via a real import.
export {}
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('submitted-details tests: ', function () {
this.beforeAll(() => {
cy.loginAndUpdateValidKey(true)
})
it('1 | clicking an open submit loads its diffs with a single postdata request', () => {
// Count every STP request the app makes during the test.
// The STP URL is `stp/execute/?_program=...` - glob segments don't match
// past `execute/` with a single `*`, so keep the glob within the path.
cy.intercept('POST', '**/SASjsApi/stp/**').as('stpExecute')
cy.visit(`${hostUrl}${appLocation}/#/review/submitted`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
// the SUBMIT queue list must be rendered with at least one row
cy.get('app-submitter clr-datagrid clr-dg-row', {
timeout: longerCommandTimeout
})
.should('exist')
.and('have.length.greaterThan', 0)
// route-based flow: clicking a row navigates to /review/submitted/:tableId
// and triggers exactly one SHOW_DIFFS (auditors/postdata) request
cy.get('app-submitter clr-datagrid clr-dg-row')
.first()
.click()
.then(() => {
cy.url().should('include', '/review/submitted/')
})
// wait for the diff table to render (the response was applied)
cy.get('app-approve-details .card', { timeout: longerCommandTimeout })
.should('exist')
.should('be.visible')
// give any (incorrect) duplicate request time to surface
cy.wait(3000)
cy.get('@stpExecute.all').then((allRequests: any) => {
// the adapter posts multipart form data, so match on the _program
// query param rather than the request body
const postdataRequests = allRequests.filter((req: any) =>
JSON.stringify(req.request.query || {}).includes('auditors/postdata')
)
expect(
postdataRequests.length,
'exactly one auditors/postdata SHOW_DIFFS request'
).to.equal(1)
// the submit queue was already fetched to render the list - opening a
// row must not fetch it again
const getsubmitsRequests = allRequests.filter((req: any) =>
JSON.stringify(req.request.query || {}).includes('editors/getsubmits')
)
expect(
getsubmitsRequests.length,
'no editors/getsubmits request after opening a submit'
).to.equal(1)
})
})
})
-349
View File
@@ -1,349 +0,0 @@
/**
* SAS Visual Analytics data-driven content, end to end.
*
* VA hands its DDC message to the embedded object as the document appears -
* before the app boots - so the app's earliest listener (src/assets/va-early.js)
* captures it, and the editor applies a pre-set filter on the very first load.
* Posting a message into a running page exercises a different path: it arrives
* after getdata, with column metadata available, so a date is formatted
* correctly there whatever the client does. Those two paths take the same
* clause builder with different inputs, so a value can be formatted correctly on
* one and not the other - which is why this spec pins the timing as well as the
* clause.
*
* sasjs/server's /ddc-host supplies the missing half: an iframe carrying VA's
* sandbox attribute set, and `timing=early` to deliver the message repeatedly
* from the moment the frame is created, so a copy lands before the app
* bootstraps. The app under test is the build streamed by the same server, so
* everything here runs against one origin and needs no SAS.
*
* Where the evidence is: the mock public/validatefilter stores what it is sent
* (`mpe_filtersource.json` on the drive) and returns the FILTER_HASH it computed
* for that clause, so the rows are read back by the hash this run's own response
* named. That is what makes the assertion about the clause DC built rather than
* about a request payload; and selecting by hash rather than emptying the store
* first is what keeps the spec out of every other spec's way - it clears nothing,
* so nothing has to run before or after it. A store that already holds this clause
* holds the same rows, and one that holds a different clause cannot be mistaken for
* this one. The host page's own log is the second witness - it records the capture
* before the frame finishes loading, which is the timing that makes the case
* meaningful. The frame's internals are deliberately not read: Cypress proxies the
* framed document, so its `contentWindow` and navigation timings cannot be
* trusted, and the drive is the authority anyway.
*/
const serverUrl = (
Cypress.env('sasjsServerUrl') || 'http://localhost:5000'
).replace(/\/$/, '')
const appLoc = Cypress.env('streamedAppLocation') || '/Public/app/dc'
const table = 'DC_JSLIB.MPE_X_TEST'
const filterSourcePath = `${appLoc}/data/DC_JSLIB/mpe_filtersource.json`
const appUrl = `${serverUrl}/AppStream/clickme/#/editor/${table}?embed=va`
const longerCommandTimeout = Cypress.env('longerCommandTimeout') || 50000
interface StoredClause {
FILTER_HASH: string
VARIABLE_NM: string
OPERATOR_NM: string
RAW_VALUE: string
PROCESSED_DTTM: number
}
/**
* A VA message carrying one parameter. `dataType: 'string'` is what a date
* parameter carries when VA displays it with a month name, and on the
* metadata-less path this type and the value's shape are all the builder has to
* go on. The `dataType: 'number'` variant is covered by the unit spec, where the
* dropped-clause branch can be driven directly.
*/
const vaMessage = (label: string, value: string, dataType = 'string') => ({
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: [{ name: 'pr1', label, dataType, value }]
})
const ddcHostUrl = (message: object) =>
`${serverUrl}/ddc-host?` +
new URLSearchParams({
url: appUrl,
message: JSON.stringify(message),
timing: 'early'
})
/** Everything the mock validatefilter has stored. */
const readStoredClauses = (): Cypress.Chainable<StoredClause[]> =>
cy
.request({
url: `${serverUrl}/SASjsApi/drive/file`,
qs: { _filePath: filterSourcePath },
failOnStatusCode: false
})
.then((res: any) => {
if (res.status === 404) return []
const body =
typeof res.body === 'string' ? JSON.parse(res.body) : res.body
return (body?.rows ?? []) as StoredClause[]
})
/**
* The candidate strings a request body carries, any of which could hold the CSV
* row: the raw text, the percent-decoded text, each form field's value, and every
* string leaf of a JSON body.
*
* Matching the raw text alone is not enough - the same row arrives wrapped in a
* multipart part, as `filterquery=...` with `%2C` for every comma, or inside a
* parsed object - and widening the delimiters instead lets a quoted literal
* through, which is the thing this guard exists to refuse.
*/
const bodyTexts = (body: any): string[] => {
const raw = typeof body === 'string' ? body : JSON.stringify(body ?? '')
const texts = [raw]
let decoded = raw
try {
decoded = decodeURIComponent(raw)
} catch (error) {
// not valid percent-encoding: the raw text is all there is
}
if (decoded !== raw) texts.push(decoded)
for (const part of decoded.split('&')) {
const index = part.indexOf('=')
if (index >= 0) texts.push(part.slice(index + 1))
}
let parsed: any = null
try {
parsed = JSON.parse(raw)
} catch (error) {
parsed = null
}
const collect = (value: any) => {
if (typeof value === 'string') texts.push(value)
else if (value && typeof value === 'object')
Object.values(value).forEach(collect)
}
collect(parsed)
return texts
}
/** The fields of a CSV row: split on the separators, nothing else. */
const csvFields = (text: string): string[] => text.split(/[,\r\n]/)
/**
* Whether the request carries a `filterquery` row for `variable`.
*
* The name has to BE a field of the candidate, not appear inside one: a
* RAW_VALUE, an SQL fragment, a quoted literal, or another variable whose name
* merely contains this one is therefore not counted as another delivery of this
* clause.
*/
const carriesClause = (body: any, variable: string): boolean =>
bodyTexts(body).some((text) =>
csvFields(text).some((field) => field.trim() === variable)
)
/**
* The FILTER_HASH the service returned for the clause in `body`.
*
* It is the service's own hash of the query, so it names this run's clause
* exactly: a clause that changed produces a different hash and the stored rows
* for the old one no longer match.
*/
const filterHashFrom = (body: any, variable: string): string => {
// The response body arrives as text, not an object: the service writes a JSON
// string and the adapter passes it through unchanged.
let payload: any = body
if (typeof body === 'string') {
try {
payload = JSON.parse(body)
} catch (error) {
throw new Error(
`the validatefilter response for ${variable} was not JSON: ` +
String(body).slice(0, 300)
)
}
}
const rows = Array.isArray(payload?.result) ? payload.result : []
const hash = rows[0]?.FILTER_HASH
if (!hash) {
throw new Error(
`the validatefilter response carried no FILTER_HASH for ${variable}: ` +
String(body).slice(0, 300)
)
}
return String(hash)
}
/** The clause stored for `variable` under `hash`, retried until it is written. */
const storedClauseFor = (
hash: string,
variable: string,
attempt = 0
): Cypress.Chainable<StoredClause> =>
readStoredClauses().then((rows: StoredClause[]) => {
const stored = rows.filter(
(row) =>
String(row.FILTER_HASH) === hash &&
String(row.VARIABLE_NM).toUpperCase() === variable
)
if (stored.length) return stored[stored.length - 1]
if (attempt > 60) {
throw new Error(
`no clause for ${variable} under ${hash} (60 reads, ${rows.length} rows)`
)
}
cy.wait(250)
return storedClauseFor(hash, variable, attempt + 1)
})
/**
* Mount the host page, then assert the two facts that make a case valid: the app
* captured the message, and it did so before the frame finished loading (the
* app's earliest listener runs at document parse, so this is the window a report
* refresh exercises). A capture that only lands after load takes the metadata
* path, which formats a date correctly whatever the client does, and so would
* prove nothing about the metadata-less path.
*/
const mountWithEarlyMessage = (message: object) => {
cy.visit(ddcHostUrl(message))
cy.get('#log', { timeout: longerCommandTimeout }).should(($log) => {
const lines = $log.text()
const captured = lines.indexOf('early replay: the app captured the message')
const loaded = lines.indexOf('frame loaded')
// The log tail goes into the message: a failure here means either the app
// never exposed its buffer or the frame never loaded, and the host page says
// which. Without it the failure is only "expected -1 to be above -1".
expect(
captured,
`the app captured the message. Log: ${lines.slice(-500)}`
).to.be.greaterThan(-1)
expect(loaded, 'the frame finished loading').to.be.greaterThan(-1)
expect(captured, 'capture happened before the frame loaded').to.be.lessThan(
loaded
)
})
}
/**
* Run one case and return the clause the service stored for it.
*
* The hash is read from the response to this case's own validatefilter call, so
* the rows are selected by what this run sent rather than by whatever the store
* happened to hold. Nothing is cleared first, which is why this spec needs no
* position in the run order.
*
* Which response is "this case's" is decided by the clause itself: the request
* body carries the filterquery CSV, so a call naming a different variable is
* ignored and cannot overwrite the hash. Two calls carrying the same variable
* would be the same clause, but they would also mean the harness is delivering
* twice - so the case fails on that count rather than silently taking the last.
*/
const applyVaFilter = (
message: object,
variable: string
): Cypress.Chainable<StoredClause> => {
const capture: { calls: number; hash: string; error: Error | null } = {
calls: 0,
hash: '',
error: null
}
cy.intercept({ method: 'POST', url: '**/*validatefilter*' }, (req) => {
const forThisCase = carriesClause(req.body, variable)
req.continue((res) => {
if (!forThisCase) return
capture.calls++
// An exception thrown here surfaces as a Cypress uncaught error rather
// than as this case failing, so it is kept and rethrown in the chain.
try {
capture.hash = filterHashFrom(res.body, variable)
} catch (error) {
capture.error = error as Error
}
})
}).as('validatefilter')
// The apply's own request sequence: validatefilter, then the data load for the
// filtered route. Waiting for the load is the app's completion signal, so the
// duplicate check below does not rest on elapsed time.
cy.intercept({ method: 'POST', url: '**/*getdata*' }).as('getdata')
mountWithEarlyMessage(message)
return cy
.wait('@validatefilter', { timeout: longerCommandTimeout })
.then(() => {
if (capture.error) throw capture.error
// Before anything is read back: an empty hash means no delivery matched
// this case - every body was refused by carriesClause, or the response
// carried no FILTER_HASH - and storedClauseFor('') would spend its retries
// and then report a missing row, naming neither cause.
expect(
capture.hash,
'validatefilter FILTER_HASH for this case'
).to.not.equal('')
return storedClauseFor(capture.hash, variable)
})
.then((clause: StoredClause) =>
// Counted after the data load rather than after a pause: the app issues the
// load only once the filter request has been answered, so a second POST
// from the same apply has been made by then, and nothing here depends on
// how fast the runner is.
cy.wait('@getdata', { timeout: longerCommandTimeout }).then(() => {
expect(
capture.calls,
`validatefilter calls carrying ${variable} in this case`
).to.equal(1)
return clause
})
)
}
context('VA data-driven content through /ddc-host:', () => {
it('1 | stores a month-name date as a date literal', () => {
// `August 02, 1971` is how VA displays SOME_DATE=4231 under WORDDATE. A
// month-name value formatted as character cannot be compared with a numeric
// date column: on a live estate that is the PROC SQL type clash inside
// mp_filtervalidate, and on this mock estate it is the quoted literal this
// assertion rejects.
applyVaFilter(vaMessage('SOME_DATE', 'August 02, 1971'), 'SOME_DATE').then(
(clause: StoredClause) => {
expect(clause.OPERATOR_NM).to.equal('=')
expect(clause.RAW_VALUE).to.equal("'02AUG1971'd")
}
)
})
it('2 | stores a DATE9 date as a date literal (control)', () => {
// DATE9, the control: it separates a parsing problem in the month-name case
// from a harness that stopped delivering dates at all.
applyVaFilter(vaMessage('SOME_DATE', '01DEC2021'), 'SOME_DATE').then(
(clause: StoredClause) => {
expect(clause.RAW_VALUE).to.equal("'01DEC2021'd")
}
)
})
it('3 | stores a character parameter as a quoted string (control)', () => {
applyVaFilter(vaMessage('SOME_DROPDOWN', 'Option 2'), 'SOME_DROPDOWN').then(
(clause: StoredClause) => {
expect(clause.RAW_VALUE).to.equal("'Option 2'")
}
)
})
})
+37 -116
View File
@@ -793,47 +793,11 @@ const openViewViewboxes = () => {
// cy.contains retries until a matching node appears/updates - the open
// viewbox's own list entry can render before its table-name text has
// actually loaded, so a one-shot .then() lookup here would race it.
const openViewboxConfig = (viewbox_tablename: string, attempt = 1) => {
const target = viewbox_tablename.toLowerCase()
const heading = () =>
cy.contains('p', 'Configuration (', { timeout: longerCommandTimeout })
// "Configuration (#2 DC_JSLIB.MPE_ALERTS)" -> "dc_jslib.mpe_alerts". A
// substring test would accept another library's same-named table (this
// estate seeds MPE_X_TEST under two libraries) and any name that merely
// contains the target - which is the mix-up this check exists to catch.
const configuredLibDataset = (text: string) => {
const match = text.match(/#\d+\s+([^\s)]+)/)
return match ? match[1].trim().toLowerCase() : ''
}
const headingNames = (text: string) => {
const configured = configuredLibDataset(text)
return configured === target || configured.endsWith(`.${target}`)
}
// The panel heading names the viewbox it is configuring, so it - not the
// row - is what the test waits on.
heading().then(($heading) => {
if (attempt === 1 && headingNames($heading.text())) return
cy.contains('.open-viewbox', viewbox_tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
// loadData() re-selects the viewbox whose data just landed, about 600-800
// ms after it lands. Checking earlier than that passes while the panel is
// still on the target, then the re-select moves it and the test body reads
// the other viewbox's columns - so stay past the window, then check.
cy.wait(800)
heading().then(($after) => {
if (headingNames($after.text())) return
if (attempt < 4) return openViewboxConfig(viewbox_tablename, attempt + 1)
expect($after.text().toLowerCase()).to.contain(target)
})
})
const openViewboxConfig = (viewbox_tablename: string) => {
cy.contains('.open-viewbox', viewbox_tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
@@ -977,90 +941,47 @@ const setFilterWithValue = (
if (callback) callback()
}
// Expands the library and clicks each table's link, retrying the expansion if
// the picker's tree re-renders underneath it. The previous version walked the
// rendered node list and paused 300ms before looking at each one, which on a
// library of this size was ~13s of the ~20s a viewbox test took.
const openTableFromViewboxTree = (
libNameIncludes: string,
tablenames: string[]
) => {
// The argument is a fragment of the library's own name; escaping it keeps a
// dot or a bracket in that name from matching more than the node meant.
const libraryName = new RegExp(
libNameIncludes.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'i'
)
cy.get('.add-new clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
const libraryNode = () =>
cy.contains('.add-new clr-tree > clr-tree-node', libraryName, {
timeout: longerCommandTimeout
})
// Scoped to the matched library: the same table name can exist under another
// library that a previous step left expanded.
const tableLinks = ($body: JQuery) =>
$body
.find('.add-new clr-tree > clr-tree-node')
.filter((_, node) => libraryName.test(node.textContent || ''))
.first()
.find('.clr-treenode-link')
const expandLibrary = () =>
libraryNode().find('.clr-tree-node-content-container p').first().click()
const clickTableLink = (tablename: string, attempt = 1) => {
cy.get('body').then(($body) => {
const visible = tableLinks($body).filter(
(_, el) =>
(el.textContent || '')
.toLowerCase()
.includes(tablename.toLowerCase()) && Cypress.dom.isVisible(el)
)
if (visible.length) {
cy.wrap(visible.first()).click()
return
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
// the tree re-renders as the library's tables load, and a re-render
// collapses the node - expand again and look once more
if (attempt < 4) {
expandLibrary()
cy.wait(300)
clickTableLink(tablename, attempt + 1)
return
}
expect(
visible.length,
`a visible picker link for ${tablename}`
).to.be.greaterThan(0)
cy.get(viyaLib).within(() => {
cy.get('p')
.click()
.then(() => {
cy.get('.clr-treenode-link').then(async (innerNodes: any) => {
for (let innerNode of innerNodes) {
for (let tablename of tablenames) {
await pause(300)
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
}
}
}
})
})
})
}
})
}
expandLibrary()
for (const tablename of tablenames) {
clickTableLink(tablename)
}
// Each pick adds its row to the open list asynchronously, so wait for the
// rows before returning a list the caller can select from.
for (const tablename of tablenames) {
cy.contains('.open-viewbox', tablename, {
matchCase: false,
timeout: longerCommandTimeout
})
}
// A viewbox's data arrives after its row, and loadData() re-selects the box
// it just loaded ~600ms later (two nested setTimeout calls in the
// component) whenever something is already selected. Selecting inside that
// window is undone by it, which sends the next step to the other viewbox's
// column list - so let the window pass. openViewboxConfig() asserts the
// selection it makes, so a lost click still fails at the click.
cy.wait(800)
const pause = (ms: number) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(null)
}, ms)
})
}
const visitPage = (url: string) => {
Binary file not shown.
+22 -36
View File
@@ -79,34 +79,14 @@ Cypress.Commands.add(
.then(() => {
cy.wait(2000)
/**
* A key that is already working needs no update. Ask the loaded
* page, not the tree: a free tier estate renders the nav tree as
* well, so the tree cannot tell a licensed estate from an
* unlicensed one - the licence banner is what does. Reading the
* body again here also matters on an estate that shows a login
* form, where the body captured above has no tree yet.
*/
cy.get('body').then(($loaded) => {
const licenceProblem = $loaded.find('#demo-banner').length > 0
if (!licenceProblem && $loaded.find('.nav-tree').length > 0) {
return logout(() => {
return
})
}
if ($body.find('.nav-tree').length > 0) {
/**
* An estate with no key is left as it is unless the caller asked
* for a licensed one - the free tier specs are written against
* that state and must not have a key planted under them.
* If licence key is already working, then skip rest of the function
*/
if (!forceLicenceKey) {
return logout(() => {
return
})
}
return logout(() => {
return
})
} else {
const keyData = {
valid_until: moment().add(20, 'day').format('YYYY-MM-DD'),
number_of_users: 10,
@@ -126,21 +106,27 @@ Cypress.Commands.add(
cy.wait(20000)
}
return updateLicenseKeyQuick(keysGen, () => {
cy.wait(25000)
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
return logout(() => {
return
if (!forceLicenceKey) {
return logout(() => {
return
})
} else {
return updateLicenseKeyQuick(keysGen, () => {
cy.wait(25000)
return acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
return logout(() => {
return
})
})
})
})
}
})
}
)
})
}
})
})
}
+1 -1
View File
@@ -10,7 +10,7 @@ const check = (cwd) => {
onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;@handsontable/angular-wrapper@18.1.1;@handsontable/angular-wrapper@18.1.2;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;handsontable@18.1.1;handsontable@18.1.2;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
},
(error, json) => {
if (error) {
+2 -30
View File
@@ -1,19 +1,6 @@
module.exports = {
ci: {
collect: {
// One pass per URL. lhci defaults to three and takes the median, which
// triples the wall clock of the audit (13.6 minutes measured across
// seventeen runs) to steady a number that only has to clear a fixed
// threshold. Raise this if a score starts flapping.
//
// The trade to know about: with one pass there is no median per URL, so a
// single slow or unlucky navigation moves its own score rather than being
// averaged away, and the recovery is a re-run rather than a fix. The
// assertions are set with that in mind - performance at 0.4, and
// accessibility at 0.85 against the 0.87-0.96 a seeded estate measures
// (issue 497), not at the 1.00 an empty one used to score - so a flap
// here means the runner was busy, not that the app regressed.
numberOfRuns: 1,
settings: {
preset: 'desktop',
chromeFlags: '--no-sandbox --disable-dev-shm-usage'
@@ -25,6 +12,7 @@ module.exports = {
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664',
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664.MPE_X_TEST',
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups/1',
'http://localhost:5000/AppStream/clickme/#/view/usernav/users/1',
'http://localhost:5000/AppStream/clickme/#/home/excel-maps',
@@ -40,25 +28,9 @@ module.exports = {
},
assert: {
assertions: {
// 0.85, not 1: the audit now runs against a seeded estate (the mocks are
// deployed and makedata/createdemodata have run), so it measures the app
// with data in it rather than empty states. That measurement is 0.87 to
// 0.96 across the seventeen URLs - the defects it reports are real and
// tracked in issue 497. The gate is the median of those seventeen runs,
// so a single busy route does not turn a fixed threshold into a coin
// flip: a run 0.09 under the floor passes when the median clears it, and
// that is the trade this threshold makes (the performance assertion
// below accepts the same one). Use aggregationMethod 'pessimistic' -
// lhci's worst run - if the floor must hold for every route instead;
// accessibility clears 0.85 that way today, performance would not. The
// floor goes back to 1 as those findings are cleared, and 497 carries
// that condition.
//
// Per-URL overrides are not available here: lhci strips the fragment, so
// all seventeen routes are one URL to it ("1 URL(s), 17 total run(s)").
'categories:accessibility': [
'error',
{ minScore: 0.85, aggregationMethod: 'median' }
{ minScore: 1, aggregationMethod: 'median' }
],
'categories:performance': [
'error',
+2567 -2347
View File
File diff suppressed because it is too large. Load diff
+48 -51
View File
@@ -3,65 +3,63 @@
"description": "DataController Client",
"angular-cli": {},
"scripts": {
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
"audit:prod": "npm audit --omit=dev",
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
"prestart": "npm run generate-eula",
"generate-eula": "node ./src/eula.ts",
"license-checker": "node licenseChecker.js",
"prebuild": "node ./src/version.ts && npm run generate-eula && npm run license-checker",
"build": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration production",
"postbuild": "rimraf dist/3rdpartylicenses.txt",
"build-dev": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development",
"build-watch": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development --watch",
"check:archiver": "node scripts/check-archiver.mjs",
"sync": "./node_modules/.bin/watch --wait=3 \"echo Account: ${npm_config_account} && npm run deploy_${npm_config_server} --account=${npm_config_account} && notify-send 'Data Controller' 'App is synced!' ; echo 'App is synced!'\" dist",
"deploy_sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc/dev",
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"viyabuild": "cd build; ./viyabuild.sh",
"lint": "npm run lint:fix",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"watch": "ng test watch=true",
"pree2e": "webdriver-manager update",
"e2e": "protractor protractor.config.js",
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
"cypress": "cypress open",
"cy:run": "cypress run",
"audit:prod": "npm audit --omit=dev",
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
"compodoc:build": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --name 'Data Controller Client'",
"compodoc:serve": "compodoc -s --name 'Data Controller Client'",
"cy:run": "cypress run",
"cypress": "cypress open",
"deploy_sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"e2e": "protractor protractor.config.js",
"generate-eula": "node ./src/eula.ts",
"license-checker": "node licenseChecker.js",
"lighthouse": "lhci autorun",
"lint": "npm run lint:fix",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"ng": "ng",
"postbuild": "rimraf dist/3rdpartylicenses.txt",
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
"prebuild": "node ./src/version.ts && npm run generate-eula && npm run license-checker",
"pree2e": "webdriver-manager update",
"prestart": "npm run generate-eula",
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
"sync": "./node_modules/.bin/watch --wait=3 \"echo Account: ${npm_config_account} && npm run deploy_${npm_config_server} --account=${npm_config_account} && notify-send 'Data Controller' 'App is synced!' ; echo 'App is synced!'\" dist",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"test:tz-east": "TZ=Pacific/Auckland npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
"test:tz-west": "TZ=America/New_York npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
"viyabuild": "cd build; ./viyabuild.sh",
"watch": "ng test watch=true"
"ng": "ng"
},
"private": true,
"dependencies": {
"@angular/animations": "20.3.34",
"@angular/animations": "^20.3.27",
"@angular/cdk": "^20.2.14",
"@angular/common": "20.3.34",
"@angular/compiler": "20.3.34",
"@angular/core": "20.3.34",
"@angular/forms": "20.3.34",
"@angular/platform-browser": "20.3.34",
"@angular/platform-browser-dynamic": "20.3.34",
"@angular/router": "20.3.34",
"@angular/common": "^20.3.27",
"@angular/compiler": "^20.3.27",
"@angular/core": "^20.3.27",
"@angular/forms": "^20.3.27",
"@angular/platform-browser": "^20.3.27",
"@angular/platform-browser-dynamic": "^20.3.27",
"@angular/router": "^20.3.27",
"@cds/core": "^6.15.1",
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
"@clr/icons": "^13.0.2",
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "18.1.2",
"@sasjs/adapter": "5.1.2",
"@sasjs/utils": "4.3.1",
"@handsontable/angular-wrapper": "^18.0.0",
"@sasjs/adapter": "^4.17.3",
"@sasjs/utils": "^3.5.9",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35",
"base64-arraybuffer": "^0.2.0",
"browserify-cipher": "^1.0.1",
"buffer": "^5.4.3",
@@ -71,11 +69,12 @@
"d3-graphviz": "^5.0.2",
"exceljs": "^4.4.0",
"fs-extra": "^7.0.1",
"handsontable": "18.1.2",
"handsontable": "^18.0.0",
"https-browserify": "1.0.0",
"hyperformula": "^2.5.0",
"iconv-lite": "^0.5.0",
"jquery-datetimepicker": "^2.5.21",
"jsrsasign": "11.1.1",
"marked": "^5.0.0",
"moment": "^2.30.1",
"ngx-clipboard": "^16.0.0",
@@ -86,6 +85,7 @@
"save-svg-as-png": "^1.4.17",
"stream-browserify": "3.0.0",
"stream-http": "3.2.0",
"text-encoding": "^0.7.0",
"tslib": "^2.3.0",
"vm": "^0.1.0",
"webpack": "^5.91.0",
@@ -99,8 +99,8 @@
"@angular-eslint/eslint-plugin-template": "19.8.1",
"@angular-eslint/schematics": "19.8.1",
"@angular-eslint/template-parser": "19.8.1",
"@angular/cli": "20.3.37",
"@angular/compiler-cli": "20.3.34",
"@angular/cli": "^20.3.32",
"@angular/compiler-cli": "^20.3.27",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@compodoc/compodoc": "^2.0.0",
"@cypress/webpack-preprocessor": "^5.17.1",
@@ -117,7 +117,6 @@
"@types/randombytes": "^2.0.3",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"archiver": "8.0.0",
"core-js": "^2.5.4",
"cypress": "^15.19.0",
"cypress-file-upload": "^5.0.8",
@@ -149,14 +148,12 @@
"ajv": "8.18.0",
"uuid": "11.1.1",
"lighthouse": "13.4.0",
"fast-uri": "4.2.3",
"readdir-glob": {
"brace-expansion": "5.0.12",
"minimatch": "11.0.3"
"brace-expansion": "^5.0.9"
},
"exceljs": {
"archiver": "8.0.0",
"unzipper": "0.12.5"
"archiver": "^8.0.0",
"unzipper": "^0.12.5"
}
}
}
-74
View File
@@ -1,74 +0,0 @@
/* Guards the readdir-glob -> minimatch override in client/package.json.
*
* The override forces minimatch 11 where readdir-glob asks for ^10, and
* readdir-glob reaches minimatch on two paths: the directory walk, and the
* pattern/ignore matching behind archiver's glob(). Both are exercised here,
* because a matcher that breaks shows up as files silently skipped rather than
* as an error.
*
* Entries are read from archiver's own 'entry' events, so nothing has to be
* unzipped and the check needs no reader dependency.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { ZipArchive } from 'archiver'
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-arch-'))
const source = path.join(root, 'source')
const cleanup = () => fs.rmSync(root, { recursive: true, force: true })
const fail = (message) => {
console.error(message)
cleanup()
process.exit(1)
}
fs.mkdirSync(source)
fs.writeFileSync(path.join(source, 'a.txt'), 'hello')
fs.mkdirSync(path.join(source, 'sub'))
fs.writeFileSync(path.join(source, 'sub', 'b.txt'), 'world')
fs.writeFileSync(path.join(source, 'sub', 'c.md'), 'ignored by pattern')
let runs = 0
const archiveWith = (configure) =>
new Promise((resolve, reject) => {
const out = path.join(root, `test-${++runs}.zip`)
const sink = fs.createWriteStream(out)
const archive = new ZipArchive({ zlib: { level: 9 } })
const names = []
archive.on('entry', (entry) => names.push(entry.name))
archive.on('error', reject)
sink.on('error', reject)
sink.on('close', () => resolve(names))
archive.pipe(sink)
configure(archive)
archive.finalize()
})
try {
/* the directory walk: every file, nested directories included */
const walked = await archiveWith((archive) => archive.directory(source, false))
const missing = ['a.txt', 'sub/b.txt'].filter((n) => !walked.includes(n))
if (missing.length) {
fail('directory walk missed: ' + missing.join(', ') + ' (got: ' + walked.join(', ') + ')')
}
/* pattern and ignore matching, which is minimatch used directly */
const globbed = await archiveWith((archive) =>
archive.glob('**/*.txt', { cwd: source, ignore: ['sub/**'] })
)
if (!globbed.includes('a.txt')) {
fail('glob did not match a.txt (got: ' + globbed.join(', ') + ')')
}
if (globbed.some((n) => n.startsWith('sub/'))) {
fail('glob ignore did not exclude sub/ (got: ' + globbed.join(', ') + ')')
}
console.log('archiver walk ok: ' + walked.join(', '))
console.log('archiver glob ok: ' + globbed.join(', '))
cleanup()
} catch (err) {
fail('archiver error: ' + err.message)
}
+4 -7
View File
@@ -48,13 +48,10 @@ console.log(
`(${sizeBefore} -> ${sizeAfter} bytes, saved ${sizeBefore - sizeAfter})`
)
// The build cache treats node_modules as immutable, so an in-place edit
// here does not invalidate the entries compiled from this file. Measured
// on the Angular 20 builder: a marker @font-face injected into
// clr-ui.min.css was absent from a warm build's CSS and present in a cold
// one. Drop the cache so the next build re-reads the stripped stylesheet.
// CI restores its cache after this script runs, so the restore is never
// deleted by this clear.
// Webpack 5's persistent cache treats node_modules as immutable
// (snapshot.module.managedPaths default), so in-place edits don't
// invalidate cached entries. Drop the Angular build cache so the next
// build re-reads our stripped clr-ui.min.css.
const cacheDir = resolve('.angular/cache')
if (existsSync(cacheDir)) {
rmSync(cacheDir, { recursive: true, force: true })
+10 -1
View File
@@ -3,7 +3,8 @@
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td.htSubmenu .htItemWrapper::after,
[class*=ht-theme-classic] .htContextMenu table tbody tr td.htSubmenu .htItemWrapper::after,
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after {
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after,
[class*=ht-theme-classic] .pika-single .pika-next {
width: var(--ht-icon-size);
height: var(--ht-icon-size);
-webkit-mask-size: contain;
@@ -11,6 +12,14 @@
background-color: currentColor;
}
[class*=ht-theme-classic] .pika-single .pika-prev {
width: var(--ht-icon-size);
height: var(--ht-icon-size);
-webkit-mask-size: contain;
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
background-color: currentColor;
}
[class*=ht-theme-classic] .ht-page-size-section__select-wrapper::after {
width: var(--ht-icon-size);
height: var(--ht-icon-size);
-18
View File
@@ -1,5 +1,4 @@
import { QueryClause } from './models/TableData'
import { VaParmapTarget } from './shared/utils/parse-va-parmap'
/**
* Filtering cache info, to be reused when filtering modal is re-open
@@ -62,21 +61,6 @@ export const globals: {
* Defaults to 'live'; toggled by the Auto-apply checkbox in the editor.
*/
vaApplyMode: 'live' | 'confirm'
/**
* Explicit VA parameter -> DC column mappings, parsed from the `va_parmap`
* URL parameter (see parseVaParmap). Empty unless configured; entries override
* VA's default same-name (parameter label -> column) resolution, so two DC
* instances over one table can filter the same column from differently-named
* VA parameters.
*/
vaParamMap: Map<string, VaParmapTarget>
/**
* `va_parmap` entries that could not be used at all (not a plain SAS column
* name, or missing a side of the `-`). Surfaced to the report author, because
* these are hand-written in the report's URL and a typo otherwise looks like
* a working, unfiltered view.
*/
vaParmapProblems: string[]
rootParam: string
dcLib: string
xlmaps: XLMapListItem[]
@@ -93,8 +77,6 @@ export const globals: {
} = {
embed: false,
vaApplyMode: 'live',
vaParamMap: new Map(),
vaParmapProblems: [],
rootParam: <string>'',
dcLib: '',
xlmaps: [],
+121 -76
View File
@@ -1,16 +1,106 @@
<div class="main-container">
@if (!router.url.includes('licensing')) {
<app-licence-banner
[startupDataLoaded]="startupDataLoaded"
[appActive]="appActive"
[freeTier]="freeTierBanner"
[expiringDays]="licenseExpiringDays"
[overCapacity]="appOverCapacity"
[licenceProblem]="licenceProblem.value"
[canRegister]="currentUserCanRegister"
[siteId]="siteId"
(problemDetails)="licenceProblemDetails($event)"
/>
@if (freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)) {
<div
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
@if (licenceProblem.value === null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - to upgrade contact
<contact-link classes="color-white" />
</div>
</div>
</div>
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
@if (licenceProblem.value !== null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="licenceProblemDetails(licenceProblem.value)"
class="update-key cursor-pointer"
>More details</a
>
}
</div>
}
@if (licenseExpiringDays && !freeTierBanner) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
This license key will expire in {{ licenseExpiringDays }}
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
@if (appOverCapacity) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
The registered number of users exceeds the limit specified for
your license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!licenseExpiringDays && !freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
}
@if (!embed) {
@@ -40,18 +130,25 @@
</div>
}
<div class="logo d-flex clr-align-items-center">
<a href="#" [routerLink]="['/']" class="nav-link">
<img
class="without-text d-block d-md-none"
src="images/dc-logo.svg"
alt="datacontroller logo without text"
/>
<img
class="with-text d-none d-md-block"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
</a>
@if (!router.url.includes('deploy')) {
<a href="#" [routerLink]="['/']" class="nav-link">
<img
class="without-text d-block d-md-none"
src="images/dc-logo.svg"
alt="datacontroller logo without text"
/>
<img
class="with-text d-none d-md-block"
src="images/datacontroller.svg"
alt="datacontroller logo"
/>
</a>
}
@if (router.url.includes('deploy')) {
<a>
<span class="clr-icon header-logo ml-10"></span>
</a>
}
</div>
@if (
!router.url.includes('deploy') && !router.url.includes('licensing')
@@ -145,7 +242,7 @@
@if (!errTop) {
<app-alerts></app-alerts>
}
<app-sas-logs-modal [(opened)]="sasLogsModal"></app-sas-logs-modal>
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
<app-excel-password-modal></app-excel-password-modal>
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
@@ -221,58 +318,6 @@
<div class="subline dec"></div>
</div>
}
@if (startupSteps.length > 0) {
<div class="startup-checks">
@for (step of startupSteps; track step.label) {
<div
class="startup-check"
[class.startup-check--error]="step.status === 'error'"
[class.startup-check--done]="step.status === 'done'"
[class.startup-check--grid]="step.details?.length"
>
@switch (step.status) {
@case ('pending') {
<clr-icon
shape="circle"
class="is-solid startup-check__icon--pending"
></clr-icon>
}
@case ('in_progress') {
<clr-spinner clrSmall></clr-spinner>
}
@case ('done') {
<clr-icon
shape="check-circle"
class="is-solid startup-check__icon--done"
></clr-icon>
}
@case ('error') {
<clr-icon
shape="exclamation-circle"
class="is-solid startup-check__icon--error"
></clr-icon>
}
}
<span class="startup-check__label">{{ step.label }}</span>
@if (step.detail) {
<span class="startup-check__detail">{{ step.detail }}</span>
}
@if (step.details?.length) {
<div class="startup-check__grid">
@for (detail of step.details; track detail.name) {
<span class="startup-check__grid-name">{{
detail.name
}}</span>
<span class="startup-check__grid-value">{{
detail.value
}}</span>
}
</div>
}
</div>
}
</div>
}
</div>
}
<!-- /App Loading Page -->
+25 -73
View File
@@ -12,7 +12,6 @@ import '@clr/icons'
import '@clr/icons/shapes/all-shapes'
import { globals } from './_globals'
import { parseEmbedParam } from './shared/utils/parse-embed-param'
import { parseVaParmap } from './shared/utils/parse-va-parmap'
import moment from 'moment'
import { EventService } from './services/event.service'
import { AppService } from './services/app.service'
@@ -20,12 +19,6 @@ import { InfoModal } from './models/InfoModal'
import { DcAdapterSettings } from './models/DcAdapterSettings'
import { AppStoreService } from './services/app-store.service'
import { LicenceService } from './services/licence.service'
import {
getSasjsTagDetails,
SASJS_TAG_STEP,
StartupCheckService,
StartupStep
} from './services/startup-check.service'
import '@cds/core/icon/register.js'
import {
ClarityIcons,
@@ -67,17 +60,10 @@ export class AppComponent {
public freeTierBanner: boolean = this.licenceService.isAppFreeTier.value
public licenceProblem = this.licenceService.licenceProblem
public appOverCapacity: boolean = false
public currentUserCanRegister: boolean = true
/** Site id(s) from the startupservice, as shown in the licence banner. */
public get siteId(): string {
return (this.syssite.getValue() || []).join(', ')
}
public appActive: boolean | null = null
public sasLogsModal: boolean = false
public requestsModal: boolean = false
public showRegistration: boolean = true
public startupDataLoaded: boolean = false
public startupSteps: StartupStep[] = []
public demoLimitNotice: { open: boolean; featureName: string } = {
open: false,
featureName: ''
@@ -95,16 +81,11 @@ export class AppComponent {
private location: Location,
private eventService: EventService,
private appStoreService: AppStoreService,
private startupCheckService: StartupCheckService,
private cdr: ChangeDetectorRef,
private elementRef: ElementRef
) {
this.parseDcAdapterSettings()
this.startupCheckService.steps$.subscribe((steps) => {
this.startupSteps = steps
})
/**
* Prints app info in the console such as:
* - Adapter versions
@@ -170,24 +151,9 @@ export class AppComponent {
globals.embed = embed
this.embed = embed
}
// VA parameter -> DC column mappings for this instance, held on globals
// (like `embed` and `vaApplyMode`) so in-app navigation that rebuilds the
// editor keeps them - e.g. returning from the stage / approve pages.
if (globals.embed === 'va') {
const parmap = parseVaParmap(hashQuery ?? '')
globals.vaParamMap = parmap.map
globals.vaParmapProblems = parmap.problems
// Visible in the browser console as well as the editor banner: the entry
// is hand-written in the report's URL, so a typo has to be findable when
// someone is looking at the report rather than at DC's UI.
for (const problem of parmap.problems) {
// eslint-disable-next-line no-console
console.warn('Data Controller: ' + problem)
}
}
this.subscribeToShowAbortModal()
this.subscribeToSasLogsModal()
this.subscribeToRequestsModal()
this.subscribeToStartupData()
this.subscribeToAppActive()
this.subscribeToDemoLimitModal()
@@ -214,45 +180,40 @@ export class AppComponent {
* Parses adapter settings that are found in the <sasjs> tag inside index.html
*/
private parseDcAdapterSettings() {
/* The <sasjs> tag is a mandatory part of index.html - it carries serverUrl,
serverType and the rest of the adapter config - so there is no config to
fall back on when it is absent. A missing one is reported on the boot
screen below rather than handled with an abort modal. */
const sasjsElement = document.querySelector('sasjs')
if (!sasjsElement) {
this.licenceService.deactivateApp()
setTimeout(() => {
this.eventService.showAbortModal(
null,
"Please make sure 'SASJS' tag with config attributes is added to index.html",
null,
'SASjs Config not found'
)
})
return
}
const getAppAttribute = (attribute: string) =>
sasjsElement?.getAttribute(attribute) || undefined
sasjsElement.getAttribute(attribute) || undefined
const dcAdapterSettings = {
serverUrl: getAppAttribute('serverUrl') || '',
appLoc: getAppAttribute('appLoc') || '',
serverType: getAppAttribute('serverType'),
loginMechanism: getAppAttribute('loginMechanism') || '',
adminGroup: getAppAttribute('adminGroup') || '',
dcPath: getAppAttribute('dcPath') || '',
debug: getAppAttribute('debug') === 'true' || false,
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
contextName: getAppAttribute('contextName') || ''
contextName: getAppAttribute('contextName') || '',
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
}
this.dcAdapterSettings = dcAdapterSettings as any
this.appStoreService.setDcAdapterSettings(dcAdapterSettings as any)
/* Show the <sasjs> tag contents on the boot screen, so the effective
configuration (serverType, debug, useComputeApi, ...) is visible without
having to read the page source. The tag is mandatory - without it the
adapter has no serverUrl or serverType - so a missing one is called out
plainly rather than silently leaving the step pending. */
if (sasjsElement) {
this.startupCheckService.setTagDetails(getSasjsTagDetails(sasjsElement))
} else {
this.startupCheckService.updateStep(
SASJS_TAG_STEP,
'error',
'SASJS TAG MISSING!!'
)
}
this.appService.sasServiceInit()
}
@@ -316,12 +277,6 @@ export class AppComponent {
this.appOverCapacity = isAppOverAppCapacity
}
)
this.licenceService.currentUserCanRegister.subscribe(
(canRegister: boolean) => {
this.currentUserCanRegister = canRegister
}
)
}
/**
@@ -342,12 +297,9 @@ export class AppComponent {
*/
public subscribeToShowAbortModal() {
this.eventService.onShowAbortModal.subscribe((sasjsAbort: InfoModal) => {
/* One message at a time. Pushing to the array stacked a second modal on
top of the one already open - two dismissals for one failure, and the
lower one was often a duplicate of the upper. Replacing keeps the
newest message, which is the one the user has to act on. */
sasjsAbort.id = Date.now()
this.sasjsAborts = [sasjsAbort]
let abortId = this.sasjsAborts.length + 1
sasjsAbort.id = abortId
this.sasjsAborts.push(sasjsAbort)
this.cdr.detectChanges() //Changes were not triggered while hot is focused
})
}
@@ -365,9 +317,9 @@ export class AppComponent {
/**
* Opens requests modal when requested from event service
*/
public subscribeToSasLogsModal() {
this.eventService.onSasLogsModalOpen.subscribe((value: boolean) => {
this.sasLogsModal = true
public subscribeToRequestsModal() {
this.eventService.onRequestsModalOpen.subscribe((value: boolean) => {
this.requestsModal = true
})
}
+4 -4
View File
@@ -6,7 +6,6 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { ClarityModule } from '@clr/angular'
import { AppComponent } from './app.component'
import { LicenceBannerComponent } from './licence-banner/licence-banner.component'
import { ROUTING } from './app.routing'
import { NotFoundComponent } from './not-found/not-found.component'
@@ -16,10 +15,11 @@ import { SharedModule } from './shared/shared.module'
import { AppSharedModule } from './app-shared.module'
import { PipesModule } from './pipes/pipes.module'
import { ReviewRouteComponent } from './routes/review-route/review-route.component'
import { LicensingGuard } from './routes/licensing.guard'
import { UsernavRouteComponent } from './routes/usernav-route/usernav-route.component'
import { AppService } from './services/app.service'
import { InfoModalComponent } from './shared/abort-modal/info-modal.component'
import { SasLogsModalComponent } from './shared/sas-logs-modal/sas-logs-modal.component'
import { RequestsModalComponent } from './shared/requests-modal/requests-modal.component'
import { DirectivesModule } from './directives/directives.module'
import { ViyaApiExplorerComponent } from './viya-api-explorer/viya-api-explorer.component'
import { NgxJsonViewerModule } from 'ngx-json-viewer'
@@ -28,12 +28,11 @@ import { AppSettingsService } from './services/app-settings.service'
@NgModule({
declarations: [
AppComponent,
LicenceBannerComponent,
NotFoundComponent,
ReviewRouteComponent,
ReviewRouteComponent,
UsernavRouteComponent,
SasLogsModalComponent,
RequestsModalComponent,
InfoModalComponent,
ViyaApiExplorerComponent
],
@@ -54,6 +53,7 @@ import { AppSettingsService } from './services/app-settings.service'
providers: [
AppService,
SasStoreService,
LicensingGuard,
AppSettingsService,
provideHttpClient(withInterceptorsFromDi())
]
@@ -2,7 +2,10 @@ import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { DeployComponent } from './deploy.component'
const routes: Routes = [{ path: '', component: DeployComponent }]
const routes: Routes = [
{ path: '', component: DeployComponent },
{ path: 'manualdeploy', component: DeployComponent }
]
@NgModule({
imports: [RouterModule.forChild(routes)],
+39 -42
View File
@@ -1,36 +1,7 @@
<main class="content-area position-relative">
<!-- An embedded instance (a report object) has no report author who can deploy:
showing the wizard there only produces a screen whose buttons cannot work,
because the deployment needs an authenticated, non-embedded session. Name
the state instead, and keep the wizard for standalone use. -->
@if (isEmbedded) {
<div class="clr-row">
<div id="not-configured" class="card">
<div class="card-header">Data Controller is not configured</div>
<div class="card-block">
<div class="card-text">
<p class="mt-0">
This Data Controller instance has not been deployed yet, so it
cannot be shown inside a report.
</p>
<p>
Deployment has to be done once, in its own browser tab, by someone
who can write to the application location:
<a [href]="deployUrl" target="_blank">{{ deployUrl }}</a>
</p>
<p>
When it is configured, reload the report and this object will show
the editor.
</p>
</div>
</div>
</div>
</div>
}
<div class="clr-row">
<!-- T&C section -->
@if (!isEmbedded && step === 0) {
@if (step === 0) {
<div id="TCS" class="card">
<div class="card-header">Terms and Conditions</div>
<div class="card-block">
@@ -58,26 +29,52 @@
></label
>
</clr-checkbox-wrapper>
<!-- <hr />
<div class="clr-checkbox-wrapper">
<input
[(ngModel)]="autodeploy"
type="checkbox"
id="checkbox2"
class="clr-checkbox"
checked
/>
<label for="checkbox2"
>Autodeploy
{{ !jsonFile ? '(json file is not available)' : '' }}</label
>
</div> -->
</div>
</div>
}
</div>
<!-- T&C section end -->
@if (!isEmbedded && step > 0) {
@if (step > 0) {
@switch (true) {
@case (sasJsConfig.serverType === ServerType.SasViya) {
<div class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
[appLoc]="appLoc"
[dcPath]="dcPath"
[selectedAdminGroup]="selectedAdminGroup"
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
@if (autodeploy) {
<div class="autodeploy-section card">
<app-automatic-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
[appLoc]="appLoc"
[dcPath]="dcPath"
[selectedAdminGroup]="selectedAdminGroup"
(onNavigateToHome)="onNavigateToHome()"
></app-automatic-deploy>
</div>
}
@if (!autodeploy) {
<div id="mainbody" class="card">
<app-manual-deploy
[sasJs]="sasJs"
[sasJsConfig]="sasJsConfig"
[dcAdapterSettings]="dcAdapterSettings"
(onNavigateToHome)="onNavigateToHome()"
></app-manual-deploy>
</div>
}
}
@case (sasJsConfig.serverType === ServerType.Sasjs) {
<div class="autodeploy-section card">
@@ -1,229 +0,0 @@
import { NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { ServerType } from '@sasjs/utils/types/serverType'
import { DeployComponent } from './deploy.component'
import { standaloneUrl } from './standalone-url'
import { globals } from '../_globals'
import { AppStoreService } from '../services/app-store.service'
import { SasService } from '../services/sas.service'
/**
* An embedded instance cannot deploy itself: the deployment needs an
* authenticated, standalone session, and the person looking at a report object
* is not the person who installs the app. The component therefore withholds the
* wizard and names the state instead.
*
* These cases render the template, so they fail if the `!isEmbedded` guards in
* deploy.component.html are dropped - asserting the getter alone would not.
*/
describe('DeployComponent embed guard', () => {
let fixture: ComponentFixture<DeployComponent>
let component: DeployComponent
const originalEmbed = globals.embed
const configure = () =>
TestBed.configureTestingModule({
declarations: [DeployComponent],
providers: [
{
provide: AppStoreService,
useValue: {
getDcAdapterSettings: () => ({
appLoc: '/Public/app/dc',
dcPath: '/tmp/dc'
})
}
},
{
provide: SasService,
useValue: {
getSasjsInstance: () => ({}),
getSasjsConfig: () => ({ serverType: ServerType.Sasjs })
}
}
],
// The wizard renders child components this spec does not exercise.
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents()
const render = () => {
fixture = TestBed.createComponent(DeployComponent)
component = fixture.componentInstance
fixture.detectChanges()
}
const tcsSection = () =>
fixture.nativeElement.querySelector('#TCS') as HTMLElement | null
const notConfigured = () =>
fixture.nativeElement.querySelector('#not-configured') as HTMLElement | null
const deploySection = () =>
fixture.nativeElement.querySelector(
'.autodeploy-section'
) as HTMLElement | null
afterEach(() => {
globals.embed = originalEmbed
TestBed.resetTestingModule()
})
it('offers the wizard when running in its own tab', () => {
globals.embed = false
configure()
render()
expect(tcsSection()).toBeTruthy()
expect(notConfigured()).toBeNull()
expect(component.isEmbedded).toBeFalse()
})
it('replaces the wizard with the notice for a VA embed', () => {
globals.embed = 'va'
configure()
render()
expect(tcsSection()).toBeNull()
expect(notConfigured()).toBeTruthy()
})
it('replaces the wizard with the notice for a plain embed too', () => {
globals.embed = true
configure()
render()
expect(tcsSection()).toBeNull()
expect(notConfigured()).toBeTruthy()
})
it('names the standalone URL in the embedded notice', () => {
globals.embed = 'va'
configure()
render()
const link = notConfigured()!.querySelector('a') as HTMLAnchorElement
expect(link.getAttribute('href')).toBe(component.deployUrl)
expect(link.textContent).toBe(component.deployUrl)
})
it('withholds the deploy section after the terms are accepted, when embedded', () => {
globals.embed = 'va'
configure()
render()
component.step = 1
fixture.detectChanges()
expect(deploySection()).toBeNull()
})
it('shows the deploy section after the terms are accepted, standalone', () => {
globals.embed = false
configure()
render()
component.step = 1
fixture.detectChanges()
expect(deploySection()).toBeTruthy()
})
})
describe('DeployComponent onNavigateToHome', () => {
const originalEmbed = globals.embed
const originalHref = window.location.href
afterEach(() => {
globals.embed = originalEmbed
history.replaceState(null, '', originalHref)
TestBed.resetTestingModule()
})
it('opens the standalone URL, merging an embed tab into the app URL', () => {
// Karma serves the runner with its own query, so the app URL is set here
// rather than read from window.location: the assertion has to see the
// app's query survive the embed parameters being dropped. The shape is
// the real SAS 9 one - the app's `_program` query in the search part,
// the embed switches in the hash query.
history.replaceState(
null,
'',
'/deploy-tc?_program=/Public/app/dc&_debug=131#/editor?embed=va&va_parmap=A-B'
)
const open = spyOn(window, 'open')
TestBed.configureTestingModule({
declarations: [DeployComponent],
providers: [
{
provide: AppStoreService,
useValue: { getDcAdapterSettings: () => ({ appLoc: '', dcPath: '' }) }
},
{
provide: SasService,
useValue: {
getSasjsInstance: () => ({}),
getSasjsConfig: () => ({ serverType: ServerType.Sasjs })
}
}
],
schemas: [NO_ERRORS_SCHEMA]
})
const fixture = TestBed.createComponent(DeployComponent)
fixture.componentInstance.onNavigateToHome()
const opened = open.calls.mostRecent().args[0] as string
expect(opened).toBe(
window.location.origin + '/deploy-tc?_program=/Public/app/dc&_debug=131'
)
expect(opened).not.toContain('#')
expect(opened).not.toContain('embed=')
})
})
describe('standaloneUrl', () => {
const origin = window.location.origin
it('keeps the path of a plain URL', () => {
expect(standaloneUrl(`${origin}/SASJobExecution/dc`)).toBe(
`${origin}/SASJobExecution/dc`
)
})
it('keeps a query the app needs, and drops the hash route', () => {
expect(standaloneUrl(`${origin}/path?x=1#/deploy`)).toBe(
`${origin}/path?x=1`
)
})
it('keeps `_program=`, which is how SAS 9 and Viya address the app', () => {
// The whole app URL is the query here: /SASJobExecution/?_program=... is
// the standalone app, so dropping the query would open a blank executor.
const sas9 =
`${origin}/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice` +
'&_debug=131'
expect(standaloneUrl(sas9)).toBe(sas9)
})
it('drops a bare embed query', () => {
expect(standaloneUrl(`${origin}/app?embed=va`)).toBe(`${origin}/app`)
})
it('drops the va_parmap entries along with the embed switch', () => {
expect(
standaloneUrl(
`${origin}/app?embed=va&va_parmap=A-B&va_parmap=C-D#/editor/x`
)
).toBe(`${origin}/app`)
})
it('keeps a query parameter that merely starts with va_', () => {
expect(standaloneUrl(`${origin}/app?embed=va&va_rowlimit=100`)).toBe(
`${origin}/app?va_rowlimit=100`
)
})
it('resolves a relative URL against the current origin', () => {
expect(standaloneUrl('/app/#/deploy')).toBe(`${origin}/app/`)
})
})
+58 -21
View File
@@ -1,11 +1,11 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
import { SasService } from '../services/sas.service'
import { SASjsConfig } from '@sasjs/adapter'
import { Router } from '@angular/router'
import { LoggerService } from '../services/logger.service'
import { ServerType } from '@sasjs/utils/types/serverType'
import { AppStoreService } from '../services/app-store.service'
import { DcAdapterSettings } from '../models/DcAdapterSettings'
import { globals } from '../_globals'
import { standaloneUrl } from './standalone-url'
@Component({
selector: 'app-deploy',
@@ -19,11 +19,17 @@ import { standaloneUrl } from './standalone-url'
})
export class DeployComponent implements OnInit {
public step: number = 0
public adminGroups: any = []
public client_id: string = ''
public client_secret: string = ''
public appLoc: string = ''
public dcPath: string = ''
public selectedAdminGroup: string = ''
public autodeploy: boolean = true
public jsonFile: any = null
public sasJs: any
public sasJsConfig: SASjsConfig = new SASjsConfig()
@@ -33,51 +39,82 @@ export class DeployComponent implements OnInit {
constructor(
private appStoreService: AppStoreService,
private sasService: SasService
private sasService: SasService,
private loggerService: LoggerService,
private router: Router
) {
this.dcAdapterSettings = this.appStoreService.getDcAdapterSettings()
if (this.router.url.includes('manualdeploy')) {
this.autodeploy = false
}
this.sasJs = this.sasService.getSasjsInstance()
this.sasJsConfig = this.sasService.getSasjsConfig()
this.appLoc = this.dcAdapterSettings?.appLoc || ''
this.client_id = localStorage.getItem('deploy_client_id') || ''
this.client_secret = localStorage.getItem('deploy_secret_key') || ''
this.dcPath = localStorage.getItem('deploy_dc_loc') || ''
}
ngOnInit() {
this.setDeployDefaults()
}
/**
* True when DC is running inside a report rather than in its own tab. The
* deployment needs an authenticated, standalone session, so the wizard is not
* offered here - a report object cannot deploy the app that it is displaying.
*/
get isEmbedded(): boolean {
return !!globals.embed
}
/** The standalone URL an administrator deploys from, for the embedded notice. */
get deployUrl(): string {
return standaloneUrl(window.location.href)
}
/**
* Setting default values used for deploy request
*/
public setDeployDefaults() {
this.dcPath = this.dcAdapterSettings?.dcPath || ''
this.selectedAdminGroup = 'SASAdministrators'
this.selectedAdminGroup = this.dcAdapterSettings?.adminGroup || ''
if (!this.selectedAdminGroup) {
this.selectedAdminGroup = 'SASAdministrators'
}
}
/**
* Accepting terms of service shows next screen
*/
public termsAgreeChange() {
if (!this.autodeploy) {
this.getAdminGroups()
}
this.step++
}
/**
* Fetches admin groups from VIYA to be selected for a backend deploy
*/
public getAdminGroups() {
fetch(
this.sasJsConfig.serverUrl + '/identities/groups?sortBy=name&limit=5000',
{
headers: {
Accept: 'application/json'
}
}
)
.then((res: any) => {
return res.text()
})
.then((res: any) => {
let jsonRes
try {
jsonRes = JSON.parse(res)
} catch (err: any) {
console.error(err)
}
if (jsonRes) {
this.adminGroups = jsonRes.items
this.selectedAdminGroup = this.adminGroups[0].id
}
})
}
public onNavigateToHome() {
// Same helper as deployUrl: splitting on '#' left the query string in
// place, so a tab opened from `?embed=va#/deploy` stayed embedded.
window.open(standaloneUrl(window.location.href), '_blank')
window.open(location.href.split('#')[0], '_blank')
}
}
+3 -3
View File
@@ -2,8 +2,8 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DeployComponent } from './deploy.component'
import { AutomaticComponent } from './sections/automatic/automatic.component'
import { ManualComponent } from './sections/manual/manual.component'
import { SasjsConfiguratorComponent } from './sections/sasjs-configurator/sasjs-configurator.component'
import { DcLocationComponent } from './sections/dc-location/dc-location.component'
import { ClarityModule } from '@clr/angular'
import { FormsModule } from '@angular/forms'
import { DeployRoutingModule } from './deploy-routing.module'
@@ -12,8 +12,8 @@ import { DeployRoutingModule } from './deploy-routing.module'
declarations: [
DeployComponent,
AutomaticComponent,
SasjsConfiguratorComponent,
DcLocationComponent
ManualComponent,
SasjsConfiguratorComponent
],
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
})
@@ -8,6 +8,20 @@
@if (autodeployDone) {
<p class="m-0 align-self-start">Done</p>
<hr class="w-100" />
@if (autoDeployStatus.deployServicePack !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.deployServicePack === true) {
<clr-icon
class="deploy-success"
shape="success-standard"
></clr-icon>
}
@if (!autoDeployStatus.deployServicePack === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Deploy SAS Jobs</p>
</div>
}
@if (autoDeployStatus.runMakeData !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.runMakeData) {
@@ -19,13 +33,16 @@
@if (autoDeployStatus.runMakeData === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Create control library</p>
<p>Create database</p>
</div>
}
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
@if (autoDeployStatus.runMakeData === false) {
@if (
autoDeployStatus.deployServicePack === false ||
autoDeployStatus.runMakeData === false
) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
LAUNCH
@@ -48,10 +65,10 @@
Close
</button>
<button
(click)="openSasLogsModal()"
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
SAS Logs
SAS Requests
</button>
</div>
}
@@ -60,187 +77,86 @@
}
<h4 class="text-center my-15">Viya Deploy</h4>
<p class="deploy-intro">
Configure Data Controller for your Viya environment. The frontend and services
are already in place; this runs the one-off job that creates the control
library and its configuration data, then removes itself.
</p>
<hr />
<div class="deploy-field">
<label class="clr-control-label">App Loc</label>
<p class="deploy-field-description">
The SAS drive folder in which the Data Controller frontend and services are
deployed. Derived from your deployment configuration &mdash; it cannot be
changed here.
</p>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<p class="m-0">{{ appLoc }}</p>
</div>
<label for="dcloc" class="mt-20 clr-control-label">App Loc</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<p class="mt-0">{{ appLoc }}</p>
</div>
</div>
<div class="deploy-field">
<label for="adminGroup" class="clr-control-label">SAS Admin group</label>
<p class="deploy-field-description">
The group you select becomes the Data Controller Admin Group &mdash;
everyone in it will have unrestricted access to Data Controller. By default
the list is filtered to the groups your identity
@if (currentUserInfo?.id) {
(<strong>{{ currentUserInfo?.id }}</strong
>)
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
<div class="mb-10 clr-control-container dc-loc-input-wrapper">
<div class="clr-input-wrapper small-mt">
<input clrInput name="dcloc" [(ngModel)]="dcPath" />
</div>
</div>
<label for="dcloc" class="mt-20 clr-control-label">SAS Admin group</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!adminGroupsLoading) {
<select clrSelect name="options" [(ngModel)]="selectedAdminGroup">
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
</select>
}
@if (adminGroupsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
is a member of. Toggle below to list every group on the Viya server.
</p>
<clr-toggle-container class="mt-0">
<clr-toggle-wrapper>
<input
id="show-all-groups-toggle"
type="checkbox"
[(ngModel)]="showAllGroups"
(ngModelChange)="onShowAllGroupsChange()"
clrToggle
/>
<label for="show-all-groups-toggle">Show all groups</label>
</clr-toggle-wrapper>
</clr-toggle-container>
<div class="clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!adminGroupsLoading) {
<select clrSelect name="adminGroup" [(ngModel)]="selectedAdminGroup">
@for (adminGroup of adminGroups; track adminGroup) {
<option [value]="adminGroup.id">
{{ adminGroup.name }}
</option>
}
</select>
}
@if (adminGroupsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</div>
</div>
@if (!showAllContexts) {
<div class="deploy-field">
<label for="batchId" class="clr-control-label">Batch ID</label>
<p class="deploy-field-description">
The batch identity (<code>runServerAs</code>) under which Data Controller
jobs will execute. It is used to filter the Compute Context dropdown below
to only the contexts running as this identity.
</p>
<div class="clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!computeContextsLoading) {
<select
clrSelect
name="batchId"
[(ngModel)]="selectedBatchId"
(ngModelChange)="onBatchIdChange()"
>
@for (batchId of batchIds; track batchId) {
<option [value]="batchId">
{{ batchId }}
</option>
}
</select>
<label for="computeContext" class="mt-20 clr-control-label"
>Compute Context</label
>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!computeContextsLoading) {
<select
clrSelect
name="options"
(ngModelChange)="onComputeContextChange($event)"
[(ngModel)]="selectedComputeContext"
>
@for (computeContext of computeContexts; track computeContext) {
<option [value]="computeContext.id">
{{ computeContext.name }}
</option>
}
@if (computeContextsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</div>
</div>
}
<div class="deploy-field">
<label for="computeContext" class="clr-control-label">Compute Context</label>
<p class="deploy-field-description">
The compute context used to run the Data Controller services (including the
<code>makedata</code> deployment job). Only reusable contexts matching the
selected Batch ID are shown, unless the toggle below is enabled.
</p>
<clr-toggle-container class="mt-0">
<clr-toggle-wrapper>
<input
id="show-all-contexts-toggle"
type="checkbox"
[(ngModel)]="showAllContexts"
(ngModelChange)="onShowAllContextsChange()"
clrToggle
/>
<label for="show-all-contexts-toggle">Show all compute contexts</label>
</clr-toggle-wrapper>
</clr-toggle-container>
<div class="clr-control-container">
<div class="clr-input-wrapper small-mt">
@if (!computeContextsLoading) {
<select
clrSelect
name="computeContext"
(ngModelChange)="onComputeContextChange($event)"
[(ngModel)]="selectedComputeContext"
>
@for (
computeContext of getFilteredComputeContexts();
track computeContext
) {
<option [value]="computeContext.id">
{{ computeContext.name }}
</option>
}
</select>
}
@if (computeContextsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</select>
}
@if (computeContextsLoading) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
</div>
</div>
@if (runningAsUser) {
<div class="deploy-field">
<label class="clr-control-label">Running as user</label>
<p class="deploy-field-description">
The identity under which Data Controller jobs will run with the selected
compute context. This user must be able to write to the DC Loc below.
</p>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<p class="m-0">
<strong>{{ runningAsUser }}</strong>
</p>
</div>
<label for="dcloc" class="mt-20 clr-control-label">Running as user:</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<p class="mt-0">{{ runningAsUser }}</p>
</div>
</div>
}
<!--
DC Loc comes last, after the compute context: the folder listing and the
write check run inside the chosen context, as its batch identity, so the
context has to be selected before the directory can be probed.
-->
<div class="deploy-field">
<label for="dcloc" class="clr-control-label">DC Loc</label>
<p class="deploy-field-description">
A physical directory on the compute server to which
<strong>{{ runningAsUser || 'the deployment user' }}</strong> can write.
Data Controller stores its control tables, staging area and audit history
here.
</p>
<div class="clr-control-container dc-loc-input-wrapper">
<app-dc-location
[(path)]="dcPath"
inputId="dcloc"
[computeContextName]="selectedComputeContextName()"
></app-dc-location>
</div>
</div>
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <clr-checkbox-wrapper>
<input
clrCheckbox
[(ngModel)]="recreateDatabase"
(click)="recreateDatabaseClicked($event)"
type="checkbox"
checked
/>
<label>Recreate database</label>
</clr-checkbox-wrapper> -->
<!-- Runs the one-off makedata job. This is the screen's only action: the
frontend and services are already deployed. -->
<hr />
<button
@@ -249,3 +165,53 @@
>
Deploy
</button>
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <button
(click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile"
>
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
</button> -->
<!-- <button
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
>
Upload different file to deploy
</button>
<input
#uploadJsonAuto
type="file"
hidden
(click)="clearUploadInput($event)"
(change)="onJsonFileChange($event)"
/> -->
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
<h3 class="modal-title">Warning</h3>
<div class="modal-body">
This action will recreate the database (if it exists). For an initial
deployment, this is expected. If this is a re-deployment, you will lose any
existing tables in <strong>{{ dcPath }}</strong
>.
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-primary"
(click)="recreateDatabaseModal = false; recreateDatabase = false"
>
Cancel
</button>
<button
type="button"
class="btn btn-sm btn-primary"
(click)="recreateDatabaseModal = false; recreateDatabase = true"
>
Ok
</button>
</div>
</clr-modal>
@@ -1,325 +0,0 @@
import { Component, NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { SASjsConfig } from '@sasjs/adapter'
import { DeployService } from 'src/app/services/deploy.service'
import { EventService } from 'src/app/services/event.service'
import { HelperService } from 'src/app/services/helper.service'
import { LoggerService } from 'src/app/services/logger.service'
import { SasService } from 'src/app/services/sas.service'
import { SasViyaService } from 'src/app/services/sas-viya.service'
import { DcLocationComponent } from '../dc-location/dc-location.component'
import { AutomaticComponent } from './automatic.component'
/**
* The Viya setup screen has one job: run `makedata` with the selections the
* form collected - the admin group, the DC path, and the compute context the
* job runs in. These tests pin that payload and the new-window URL, because a
* screen whose only action silently stopped calling the service would still
* compile, still render, and still look like it worked.
*/
describe('AutomaticComponent (Viya setup)', () => {
const build = () => {
const requests: any[][] = []
const sasJs: any = {
request: (...args: any[]) => {
requests.push(args)
return Promise.resolve({ result: [{}] })
}
}
const sasService: any = {
getSasjsConfig: () => new SASjsConfig(),
getExecutionPath: () => '/SASJobExecution/',
shouldLogin: { next: () => undefined },
viyaMakedataSuccessfull: () => Promise.resolve(true),
updateFileContent: () => Promise.resolve()
}
const component = new AutomaticComponent(
{
showAbortModal: () => undefined,
showInfoModal: () => undefined
} as any,
{ downloadFile: () => undefined } as any,
sasService,
{} as any,
{ error: () => undefined } as any,
{} as any
)
component.sasJs = sasJs
const config = {
contextName: 'Compute Reusable',
serverUrl: 'https://viya.example.com',
appLoc: '/Users/demo/dc'
} as any
component.sasJsConfig = config
// The new-window URL is built from the adapter config the component holds,
// while the request path reads the @Input one.
component.sasjsConfig = config
component.appLoc = '/Users/demo/dc'
component.dcPath = '/export/dc'
component.selectedAdminGroup = 'viyagroup18'
return { component, requests, sasService }
}
it('posts makedata with the admin group and the DC path', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
await component.createDatabase()
expect(requests[0][0]).toBe('services/admin/makedata')
expect(requests[0][1]).toEqual({
fromjs: [
{ ACTION: 'MAKEDATA', ADMIN: 'viyagroup18', DCPATH: '/export/dc' }
]
})
})
it('runs the job in the configured context, on the web method', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
await component.createDatabase()
expect(requests[0][2]).toEqual({
useComputeApi: null,
contextName: 'Compute Reusable',
debug: true
})
})
it('prefers the context chosen in the form over the configured one', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
component.computeContexts = [
{ id: 'ctx-1', name: 'Selected Context' } as any
]
component.selectedComputeContext = 'ctx-1'
await component.createDatabase()
expect(requests[0][2].contextName).toBe('Selected Context')
})
it('names the chosen context for the directory probes', () => {
// The folder listing and the write check run in the deployment's context,
// so the picker is handed the same context the makedata job will use.
const { component } = build()
component.computeContexts = [
{ id: 'ctx-1', name: 'Selected Context' } as any
]
component.selectedComputeContext = 'ctx-1'
expect(component.selectedComputeContextName()).toBe('Selected Context')
})
it('falls back to the configured context until one is chosen', () => {
const { component } = build()
expect(component.selectedComputeContextName()).toBe('Compute Reusable')
})
it('opens makedata in a new window with the selections as URL parameters', () => {
const { component } = build()
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
spyOn(component as any, 'pollMakedataCompletion')
component.deployInNewWindow = true
component.createDatabase()
expect(opened.length).toBe(1)
expect(opened[0]).toContain('/Users/demo/dc/services/admin/makedata')
expect(opened[0]).toContain('_contextname=Compute%20Reusable')
expect(opened[0]).toContain('admin=viyagroup18')
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc')
expect(opened[0]).toContain('action=MAKEDATA')
expect(opened[0]).toContain('_debug=131')
})
it('percent-encodes the values that need it in the new-window URL', () => {
// A context name or admin group can hold characters that would otherwise
// split the query string - an ampersand would start a new parameter and a
// space would arrive truncated.
const { component } = build()
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
// The real poll loads the page once it sees makedata finish: left running
// here it fires five seconds later, during whichever spec is running then,
// and Karma aborts with "Some of your tests did a full page reload".
spyOn(component as any, 'pollMakedataCompletion')
component.runMakedataInNewWindow({
contextName: 'SAS Job Execution & co',
admin: 'group 1+2',
dcPath: '/export/dc data'
})
expect(opened[0]).toContain('_contextname=SAS%20Job%20Execution%20%26%20co')
expect(opened[0]).toContain('admin=group%201%2B2')
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc%20data')
// the parameter names themselves are not encoded
expect(opened[0]).toContain('&admin=group%201%2B2')
})
it('stops the makedata poll when the screen is destroyed', () => {
// The poll runs every five seconds for up to ten minutes and reloads the
// page the first time it sees makedata gone. It must not outlive the
// screen: a timer left behind reloads whatever the user moved on to.
const { component } = build()
spyOn(window, 'open')
component.runMakedataInNewWindow({
contextName: 'Compute Reusable',
admin: 'viyagroup18',
dcPath: '/export/dc'
})
expect((component as any).makedataPollInterval).not.toBeNull()
component.ngOnDestroy()
expect((component as any).makedataPollInterval).toBeNull()
})
it('starts the job when deploy is pressed', () => {
// The screen's only button calls this; a version where it stopped doing so
// would leave the setup page unable to do anything at all.
const { component } = build()
const spy = spyOn(component, 'createDatabase').and.returnValue(
Promise.resolve() as any
)
component.runAutoDeploy()
expect(spy).toHaveBeenCalled()
})
})
/**
* The order the fields appear in, rendered. The directory probes (folder
* listing, write check) run inside the compute context chosen on this screen,
* as its batch identity - so the context has to be picked before the directory
* is probed, and the template has to ask for them in that order. A template
* edit that silently moved DC Loc back above the context would compile, render
* and deploy fine, and offer a directory listing from the wrong identity.
*/
describe('AutomaticComponent (Viya setup) field order', () => {
@Component({
selector: 'app-automatic-deploy-host',
template:
'<app-automatic-deploy [sasJsConfig]="config"></app-automatic-deploy>',
standalone: false
})
class HostComponent {
config = new SASjsConfig()
}
let fixture: ComponentFixture<HostComponent>
const viyaService = () => ({
getComputeContexts: () => ({
subscribe: (next: any) => next({ items: [] })
}),
getComputeContextById: () => ({ subscribe: (next: any) => next({}) }),
getCurrentUser: () => ({
subscribe: (next: any) => next({ id: 'demo' })
}),
getAdminGroups: () => ({ subscribe: (next: any) => next({ items: [] }) }),
getCurrentUserGroupMemberships: () => ({
subscribe: (next: any) => next({ items: [] })
})
})
beforeEach(() => {
const sasService: any = {
getSasjsConfig: () => new SASjsConfig(),
getExecutionPath: () => '/SASJobExecution/',
shouldLogin: { next: () => undefined },
viyaMakedataSuccessfull: () => Promise.resolve(true),
updateFileContent: () => Promise.resolve(),
getServerType: () => 'SASVIYA'
}
TestBed.configureTestingModule({
declarations: [AutomaticComponent, HostComponent, DcLocationComponent],
imports: [FormsModule],
providers: [
{ provide: SasViyaService, useValue: viyaService() },
{ provide: SasService, useValue: sasService },
{ provide: HelperService, useValue: { isStreamingViya: () => false } },
{ provide: EventService, useValue: {} },
{ provide: DeployService, useValue: { downloadFile: () => undefined } },
{ provide: LoggerService, useValue: { error: () => undefined } }
],
schemas: [NO_ERRORS_SCHEMA]
})
fixture = TestBed.createComponent(HostComponent)
fixture.detectChanges()
})
afterEach(() => {
TestBed.resetTestingModule()
})
it('asks for the admin group and the compute context before the directory', async () => {
await fixture.whenStable()
fixture.detectChanges()
const labels = Array.from(
fixture.nativeElement.querySelectorAll('.clr-control-label')
).map((label: unknown) => ((label as HTMLElement).textContent || '').trim())
const indexOf = (text: string) =>
labels.findIndex((label) => label.toLowerCase().includes(text))
const admin = indexOf('admin group')
const context = indexOf('compute context')
const dcLoc = indexOf('dc loc')
expect(admin).toBeGreaterThanOrEqual(0)
expect(context).toBeGreaterThanOrEqual(0)
expect(dcLoc).toBeGreaterThanOrEqual(0)
expect(admin).toBeLessThan(context)
expect(context).toBeLessThan(dcLoc)
})
it('hands the picker the context selected on this screen', async () => {
await fixture.whenStable()
fixture.detectChanges()
const automatic = fixture.debugElement.query(
By.directive(AutomaticComponent)
).componentInstance as AutomaticComponent
const picker = () =>
fixture.debugElement.query(By.directive(DcLocationComponent))
.componentInstance as DcLocationComponent
// Until the form has a selection the picker is offered the adapter's
// configured context, which is what the probes fall back to.
expect(picker().computeContextName).toBe(automatic.sasJsConfig.contextName)
// Selecting a context on this screen has to reach the picker: the probes
// run in that context, as its batch identity.
automatic.computeContexts = [
{ id: 'ctx-1', name: 'Compute Reusable' } as any
]
automatic.selectedComputeContext = 'ctx-1'
fixture.detectChanges()
expect(picker().computeContextName).toBe('Compute Reusable')
})
})
@@ -2,7 +2,6 @@ import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewEncapsulation
@@ -26,13 +25,6 @@ import {
Item as ComputeContextItem
} from 'src/app/viya-api-explorer/models/viya-compute-contexts.model'
export interface ComputeContextInfo {
id: string
name: string
runAs: string | null
reusable: boolean
}
@Component({
selector: 'app-automatic-deploy',
templateUrl: './automatic.component.html',
@@ -40,7 +32,7 @@ export interface ComputeContextInfo {
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class AutomaticComponent implements OnInit, OnDestroy {
export class AutomaticComponent implements OnInit {
@Input() sasJs!: SASjs
@Input() sasJsConfig: SASjsConfig = new SASjsConfig()
@Input() dcAdapterSettings: DcAdapterSettings | undefined
@@ -52,40 +44,38 @@ export class AutomaticComponent implements OnInit, OnDestroy {
public selectedComputeContext: string = ''
public makeDataResponse: string = ''
public jsonFile: any = null
public autodeploying: boolean = false
public autodeployDone: boolean = false
public recreateDatabaseModal: boolean = false
public isSubmittingJson: boolean = false
public isJsonSubmitted: boolean = false
/**
* Default was `false` when deploy was done with frontend and backend separately.
* Now we are using only streaming app, so we always want to recreate database (makedata)
*/
public recreateDatabase: boolean = true
public createDatabaseLoading: boolean = false
public adminGroupsLoading: boolean = false
public currentUserInfoLoading: boolean = false
public computeContextsLoading: boolean = false
public adminGroups: { id: string; name: string }[] = []
/** Compute contexts enriched with their batch identity and reusability */
public computeContextInfos: ComputeContextInfo[] = []
/** Distinct batch identities (runServerAs) across reusable contexts */
public batchIds: string[] = []
public selectedBatchId: string = ''
/**
* When enabled, any compute context can be chosen (the batch id dropdown
* is hidden and no reusability / batch id filtering is applied)
*/
public showAllContexts: boolean = false
/** All groups on the Viya server */
public allAdminGroups: { id: string; name: string }[] = []
/** Groups the current user is a member of */
public memberAdminGroups: { id: string; name: string }[] = []
public showAllGroups: boolean = false
public runningAsUser: string | undefined
public currentUserInfo: ViyaApiCurrentUser | null = null
public computeContexts: ComputeContextItem[] = []
/**
* Status of the one step this screen runs: `makedata`, which creates the
* control library and its configuration data and then deletes itself.
* `null` means it has not run yet, `false` that it failed.
/** autoDeployStatus
* This object presents the status for two steps that we have for deploy.
* `deployServicePack` - Creating services based on `viya.json`
* `runMakeData` - Running `makedata` service
* If any of them is `null` or `false` that means step failed
* and will be shown to user on deploy done modal.
*/
public autoDeployStatus: {
deployServicePack: any
runMakeData: any
} = {
deployServicePack: null,
runMakeData: null
}
@@ -114,9 +104,9 @@ export class AutomaticComponent implements OnInit, OnDestroy {
}
public async loadData() {
await this.getCurrentUser()
await this.getAdminGroups()
await this.getComputeContexts()
await this.getCurrentUser()
setTimeout(() => {
if (this.selectedComputeContext) {
@@ -130,13 +120,20 @@ export class AutomaticComponent implements OnInit, OnDestroy {
this.computeContextsLoading = true
this.sasViyaService.getComputeContexts().subscribe(
async (res: ViyaComputeContexts) => {
this.computeContexts = res.items
await this.getComputeContextDetails()
(res: ViyaComputeContexts) => {
this.computeContextsLoading = false
const defaultContext = res.items.find(
(item: ComputeContextItem) =>
item.name === 'SAS Job Execution compute context'
)
if (defaultContext) {
this.selectedComputeContext = defaultContext.id
}
this.computeContexts = res.items
resolve()
},
(err) => {
@@ -146,137 +143,6 @@ export class AutomaticComponent implements OnInit, OnDestroy {
})
}
/**
* The `/compute/contexts` list view is minimal, so each context is fetched
* in full (in parallel) to pick up its batch identity (runServerAs) and
* whether it reuses server processes. Only reusable contexts with a batch
* identity feed the batch id dropdown.
*/
public async getComputeContextDetails() {
const infos = await Promise.all(
this.computeContexts.map(
(context: ComputeContextItem) =>
new Promise<ComputeContextInfo>((resolveInfo) => {
this.sasViyaService.getComputeContextById(context.id).subscribe(
(details: ComputeContextDetails) => {
resolveInfo({
id: context.id,
name: context.name,
runAs: this.extractRunAs(details),
reusable: details.attributes?.reuseServerProcesses === 'true'
})
},
(err: any) => {
this.loggerService.error(
`Error while getting compute context ${context.name}`,
err
)
resolveInfo({
id: context.id,
name: context.name,
runAs: null,
reusable: false
})
}
)
})
)
)
this.computeContextInfos = infos
this.batchIds = [
...new Set(
infos
.filter((info) => info.reusable && info.runAs)
.map((info) => info.runAs as string)
)
]
if (this.batchIds.length) {
this.selectedBatchId = this.batchIds[0]
}
const filteredContexts = this.getFilteredComputeContexts()
const defaultContext =
filteredContexts.find(
(info: ComputeContextInfo) =>
info.name === 'SAS Job Execution compute context'
) || filteredContexts[0]
if (defaultContext) {
this.selectedComputeContext = defaultContext.id
}
}
/**
* Pulls the runAs (batch) identity out of a compute context. The batch user
* is exposed as `attributes.runServerAs` in the Viya compute context API
* response. Not all contexts expose one.
*/
public extractRunAs(details: ComputeContextDetails): string | null {
return details.attributes?.runServerAs || null
}
/**
* The contexts offered in the dropdown. By default only reusable contexts
* (`reuseServerProcesses`) matching the selected batch id are shown. When
* the `showAllContexts` toggle is on, all contexts are shown.
*/
public getFilteredComputeContexts(): ComputeContextInfo[] {
if (this.showAllContexts) {
return this.computeContextInfos
}
return this.computeContextInfos.filter(
(info: ComputeContextInfo) =>
info.reusable && info.runAs === this.selectedBatchId
)
}
/**
* Changing the batch id re-filters the compute context dropdown and makes
* sure the currently selected context exists in the displayed list.
*/
public onBatchIdChange() {
this.ensureSelectedComputeContext()
}
/**
* Toggles the compute context dropdown between contexts matching the
* selected batch id and all contexts on the Viya server.
*/
public onShowAllContextsChange() {
this.ensureSelectedComputeContext()
}
private ensureSelectedComputeContext() {
const filteredContexts = this.getFilteredComputeContexts()
const selectedContextExists = filteredContexts.some(
(info: ComputeContextInfo) => info.id === this.selectedComputeContext
)
if (!selectedContextExists) {
this.selectedComputeContext = filteredContexts.length
? filteredContexts[0].id
: ''
}
this.updateRunningAsUser()
}
private updateRunningAsUser() {
const selectedContext = this.computeContextInfos.find(
(info: ComputeContextInfo) => info.id === this.selectedComputeContext
)
this.runningAsUser =
selectedContext?.runAs || this.currentUserInfo?.id || 'unknown'
}
public async getCurrentUser() {
return new Promise<void>((resolve, reject) => {
this.currentUserInfoLoading = true
@@ -304,21 +170,16 @@ export class AutomaticComponent implements OnInit, OnDestroy {
this.adminGroupsLoading = true
;(this.sasViyaService
.getAdminGroups()
.subscribe(async (res: ViyaApiIdentities) => {
.subscribe((res: ViyaApiIdentities) => {
this.adminGroupsLoading = false
// Map admin groups with only needed fields
this.allAdminGroups = res.items.map((item: Item) => {
this.adminGroups = res.items.map((item: Item) => {
return {
id: item.id,
name: item.name
}
})
await this.getMemberAdminGroups()
this.applyAdminGroupFilter()
this.adminGroupsLoading = false
resolve()
}),
(err: any) => {
@@ -331,72 +192,16 @@ export class AutomaticComponent implements OnInit, OnDestroy {
})
}
/**
* Fetches the groups the current user is a member of, from the Viya
* identities API. If the memberships cannot be fetched, the member list
* falls back to all groups.
*/
public async getMemberAdminGroups() {
return new Promise<void>((resolve) => {
this.sasViyaService.getCurrentUserGroupMemberships().subscribe(
(res: ViyaApiIdentities) => {
const memberIds = res.items.map((item: Item) => item.id)
this.memberAdminGroups = this.allAdminGroups.filter(
(group: { id: string; name: string }) =>
memberIds.includes(group.id)
)
resolve()
},
(err: any) => {
this.loggerService.error(
'Error while getting user group memberships',
err
)
this.memberAdminGroups = this.allAdminGroups
resolve()
}
)
})
}
/**
* Toggles the SAS Admin group dropdown between the groups the current user
* is a member of and all groups on the Viya server.
*/
public onShowAllGroupsChange() {
this.applyAdminGroupFilter()
}
/**
* Applies the member/all groups filter to the dropdown and makes sure the
* currently selected group exists in the displayed list.
*/
private applyAdminGroupFilter() {
this.adminGroups =
this.showAllGroups || !this.memberAdminGroups.length
? this.allAdminGroups
: this.memberAdminGroups
const selectedGroupExists = this.adminGroups.some(
(group: { id: string; name: string }) =>
group.id === this.selectedAdminGroup
)
if (!selectedGroupExists && this.adminGroups.length) {
this.selectedAdminGroup = this.adminGroups[0].id
}
}
public async onComputeContextChange(computeContextId: string) {
// The explicit (ngModelChange) handler runs before the [(ngModel)]
// assignment updates the field, so synchronise the selected context id
// here before deriving the running user. Otherwise the displayed user
// lags behind the dropdown by one change.
this.selectedComputeContext = computeContextId
this.updateRunningAsUser()
this.sasViyaService
.getComputeContextById(computeContextId)
.subscribe((res: ComputeContextDetails) => {
if (res.attributes && res.attributes.runServerAs) {
this.runningAsUser = res.attributes.runServerAs
} else {
this.runningAsUser = this.currentUserInfo?.id || 'unknown'
}
})
}
public getComputeContextName(id: string): string | undefined {
@@ -408,28 +213,61 @@ export class AutomaticComponent implements OnInit, OnDestroy {
}
/**
* The name of the context the makedata job - and the directory probes it
* depends on - will run in. Empty until a context is selected, so the
* picker falls back to the adapter's configured context.
* Executes sas.json file to deploy the backend
* Method will first try to run the `auto deploy`
* If that fails the rest of the code is ignored.
* If request is successfull, method will continue to try
* to create database if checkbox is toggled on
*/
public selectedComputeContextName(): string {
return (
this.getComputeContextName(this.selectedComputeContext) ||
this.sasJsConfig.contextName ||
''
)
public async executeJson() {
this.isSubmittingJson = true
try {
let uploadJsonFile = await this.sasJs.deployServicePack(
this.jsonFile,
this.dcAdapterSettings?.appLoc,
undefined,
undefined,
true
)
this.autoDeployStatus.deployServicePack = true
this.isJsonSubmitted = true
} catch (ex: any) {
let textEx = ''
if (typeof ex.message !== 'string') {
textEx = JSON.stringify(ex).replace(/\\/gm, '')
} else {
textEx = ex.message
}
this.autoDeployStatus.deployServicePack = false
this.eventService.showInfoModal(
'Deploy error',
`Exception: \n ${textEx !== '' ? textEx : ex}`
)
this.autodeploying = false
this.autodeployDone = false
return
}
this.isSubmittingJson = false
}
/**
* Runs the deployment. The control library and its configuration data are
* created by `makedata`, which self-deletes once it has run; the frontend and
* services arrived with the sasjs deploy, so there is nothing else to deploy
* from here.
*/
public runAutoDeploy() {
public async runAutoDeploy(executeJson: boolean = false) {
if (!this.deployInNewWindow) this.autodeploying = true
this.createDatabase()
if (executeJson) {
this.executeJson()
}
if (this.recreateDatabase) {
this.createDatabase()
} else {
if (!this.deployInNewWindow) this.autodeployDone = true
}
}
/**
@@ -439,7 +277,6 @@ export class AutomaticComponent implements OnInit, OnDestroy {
let data = {
fromjs: [
{
ACTION: 'MAKEDATA',
ADMIN: this.selectedAdminGroup,
DCPATH: this.dcPath
}
@@ -504,8 +341,7 @@ export class AutomaticComponent implements OnInit, OnDestroy {
this.eventService.showAbortModal('makedata', abortMsg, {
SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
SYSERRORTEXT: abortRes.SYSERRORTEXT,
MAC: macMsg,
_PROGRAM: abortRes._PROGRAM
MAC: macMsg
})
}
@@ -534,14 +370,10 @@ export class AutomaticComponent implements OnInit, OnDestroy {
let serverUrl = this.sasjsConfig.serverUrl
let appLoc = this.sasjsConfig.appLoc
const execPath = this.sasService.getExecutionPath()
let contextname = `&_contextname=${encodeURIComponent(params.contextName)}`
let admin = `&admin=${encodeURIComponent(params.admin)}`
let dcPath = `&dcpath=${encodeURIComponent(params.dcPath)}`
// makedata requires ACTION; MAKEDATA is the seeding run
let action = '&action=MAKEDATA'
// Debug is ALWAYS enabled for makedata — it runs only once during
// deployment and the full log is needed to diagnose any issues.
let debug = '&_debug=131'
let contextname = `&_contextname=${params.contextName}`
let admin = `&admin=${params.admin}`
let dcPath = `&dcpath=${params.dcPath}`
let debug = this.sasService.getDebugUrlParam()
let programUrl =
serverUrl +
@@ -552,70 +384,9 @@ export class AutomaticComponent implements OnInit, OnDestroy {
contextname +
admin +
dcPath +
action +
debug
window.open(programUrl)
// Poll for makedata completion. The makedata service self-deletes
// when it finishes successfully, so we check if it's still present
// in the admin folder. When it's gone, reload the app fresh.
this.pollMakedataCompletion()
}
private makedataPollInterval: any = null
/**
* The poll outlives the deploy screen if the user navigates away, and it
* loads the page again the moment it sees makedata finish. Stop it with the
* component, so a timer cannot reload the page from under whatever the user
* moved on to.
*/
ngOnDestroy(): void {
this.stopMakedataPoll()
}
private stopMakedataPoll(): void {
if (this.makedataPollInterval) {
clearInterval(this.makedataPollInterval)
this.makedataPollInterval = null
}
}
private pollMakedataCompletion() {
this.stopMakedataPoll()
let attempts = 0
const maxAttempts = 120 // 10 minutes at 5s intervals
this.makedataPollInterval = setInterval(async () => {
attempts++
console.log(
`pollMakedataCompletion: checking if makedata is gone (attempt ${attempts}/${maxAttempts})`
)
try {
const makedataGone = await this.sasService.viyaMakedataSuccessfull()
if (makedataGone) {
console.log(
'pollMakedataCompletion: makedata job is gone, reloading app'
)
this.stopMakedataPoll()
// Reload the app fresh from the SASJobExecution URL
window.location.href = window.location.href.split('#')[0]
}
} catch (err) {
console.error('pollMakedataCompletion: error checking', err)
}
if (attempts >= maxAttempts) {
console.warn(
'pollMakedataCompletion: timed out after 10 minutes, stopping poll'
)
this.stopMakedataPoll()
}
}, 5000)
}
/**
@@ -657,12 +428,9 @@ export class AutomaticComponent implements OnInit, OnDestroy {
return
}
/* Use a function replacement so $ in computeContextName is treated as a
literal character. In a string replacement, $ has special meaning
($&, $1, $` etc.); a function return value is used verbatim. */
const updatedContent = indexHtmlContent.replace(
/contextname="[^"]*"/g,
() => `contextname="${computeContextName}"`
`contextname="${computeContextName}"`
)
await this.sasService
@@ -680,8 +448,24 @@ export class AutomaticComponent implements OnInit, OnDestroy {
this.deployService.downloadFile(content, filename, extension)
}
public openSasLogsModal() {
this.eventService.openSasLogsModal()
public async onJsonFileChange(event: any) {
let file = event.target.files[0]
this.jsonFile = await this.deployService.readFile(file)
}
public recreateDatabaseClicked(event: Event) {
;(<HTMLInputElement>event.target).checked === true
? (this.recreateDatabaseModal = true)
: ''
}
public clearUploadInput(event: Event) {
this.deployService.clearUploadInput(event)
}
public openSasRequestsModal() {
this.eventService.openRequestsModal()
}
public navigateToHome() {
@@ -1,155 +0,0 @@
<div class="dc-location">
<!--
A plain flex row, not a Clarity `clr-input-wrapper`: that class carries
`max-height: var(--clr-forms-input-wrapper-height)`, which clamps this
row to the input's height. The row is `flex-wrap: wrap`, so the buttons
overflow the clamp, and anything after the row (the folder browser panel)
then renders on top of them.
-->
<div class="dc-location-input">
<input
clrInput
[id]="inputId"
[name]="inputId"
[attr.aria-label]="ariaLabel"
[ngModel]="path"
(ngModelChange)="onPathChange($event)"
placeholder="Type a folder, or browse below"
/>
<button
type="button"
id="{{ inputId }}-browse"
class="btn btn-sm btn-outline dc-location-button"
(click)="toggleBrowse()"
>
{{ browsing ? 'Hide' : 'Browse' }}
</button>
<button
type="button"
id="{{ inputId }}-validate"
class="btn btn-sm btn-outline dc-location-button"
[disabled]="validating"
(click)="validate()"
>
@if (validating) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
@if (!validating) {
Validate
}
</button>
</div>
@if (checkResult) {
<p
id="{{ inputId }}-validate-result"
class="dc-location-result"
[class.dc-location-ok]="checkResult.writeable"
[class.dc-location-bad]="!checkResult.writeable"
>
@if (checkResult.writeable) {
<clr-icon shape="check"></clr-icon>
<span>{{ checkResult.path }} is writeable</span>
}
@if (!checkResult.writeable) {
<clr-icon shape="exclamation-triangle"></clr-icon>
<span>{{ checkResult.msg }}</span>
}
</p>
}
@if (browsing) {
<div class="dc-browser" id="{{ inputId }}-browser">
<div class="dc-browser-bar">
<button
type="button"
id="{{ inputId }}-up"
class="btn btn-sm btn-link dc-browser-up"
[disabled]="listingIsRoot"
(click)="up()"
>
Up
</button>
<span class="dc-browser-path">
@if (listingIsRoot) {
Select a drive or root folder
}
@if (!listingIsRoot) {
{{ listingParent }}
}
</span>
@if (!listingIsRoot) {
<button
type="button"
id="{{ inputId }}-use-current"
class="btn btn-sm btn-outline dc-browser-use"
(click)="useCurrent()"
>
Use this folder
</button>
}
</div>
@if (loading) {
<div class="dc-browser-loading">
<span class="spinner spinner-sm"> Loading... </span>
</div>
}
@if (!loading && !!listingError) {
<p class="dc-location-bad" id="{{ inputId }}-browser-error">
<clr-icon shape="exclamation-triangle"></clr-icon>
<span>{{ listingError }}</span>
</p>
}
@if (!loading && !listingError) {
<ul class="dc-folder-list">
@for (folder of folders; track folder.filepath) {
<li class="dc-folder">
@if (!folder.locked) {
<button
type="button"
class="btn btn-sm btn-link dc-folder-open"
(click)="enter(folder)"
>
<clr-icon shape="folder"></clr-icon>
<span>{{ folder.filename }}</span>
</button>
}
@if (folder.locked) {
<span class="dc-folder-locked">
<clr-icon shape="lock"></clr-icon>
<span>{{ folder.filename }}</span>
</span>
}
@if (!!folder.info) {
<span class="dc-folder-info">{{ folder.info }}</span>
}
@if (!folder.locked) {
<button
type="button"
class="btn btn-sm btn-link dc-folder-use"
(click)="choose(folder)"
>
Use
</button>
}
</li>
}
@if (!folders.length) {
<li class="dc-folder dc-folder-empty" id="{{ inputId }}-no-folders">
No folders here
</li>
}
</ul>
}
@if (truncated) {
<p class="dc-browser-truncated" id="{{ inputId }}-truncated">
Only the first folders are shown - type the path if it is not listed.
</p>
}
</div>
}
</div>
@@ -1,131 +0,0 @@
.dc-location-input {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.dc-location-input input {
flex: 1 1 22rem;
min-width: 14rem;
}
.dc-location-button {
margin: 0;
}
.dc-location-result {
display: flex;
align-items: center;
gap: 6px;
margin: 6px 0 0;
font-size: 0.7rem;
}
.dc-location-ok {
color: #306b00;
}
.dc-location-bad {
color: #c21d00;
}
body[cds-theme='dark'] .dc-location-ok {
color: #7fd14a;
}
body[cds-theme='dark'] .dc-location-bad {
color: #ff8a73;
}
.dc-browser {
margin-top: 8px;
border: 1px solid light-dark(#ccc, #4a4a4a);
border-radius: 4px;
max-height: 18rem;
overflow: auto;
max-width: 100%;
}
.dc-browser-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid light-dark(#e8e8e8, #4a4a4a);
position: sticky;
top: 0;
background: light-dark(#fafafa, #1b1b1b);
z-index: 1;
}
.dc-browser-path {
flex: 1 1 auto;
font-family: monospace;
font-size: 0.7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dc-browser-up,
.dc-browser-use {
margin: 0;
}
.dc-browser-loading {
padding: 8px;
}
.dc-folder-list {
list-style: none;
margin: 0;
padding: 0;
}
.dc-folder {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
}
.dc-folder:hover {
background: light-dark(#f0f0f0, #2a2a2a);
}
.dc-folder-open {
margin: 0;
flex: 1 1 auto;
justify-content: flex-start;
}
.dc-folder-info {
font-size: 0.6rem;
opacity: 0.75;
}
.dc-folder-use {
margin: 0;
}
.dc-folder-empty {
opacity: 0.75;
font-size: 0.7rem;
padding: 8px;
}
.dc-browser-truncated {
margin: 0;
padding: 6px 8px;
font-size: 0.65rem;
opacity: 0.85;
}
.dc-folder-locked {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 auto;
opacity: 0.7;
}
Loaded 100 of 574 files, more files were not shown because too many files have changed in this diff. Show more