Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402ec346b9 | ||
|
|
e48bf80ab4 | ||
|
|
dd958ac4e7 | ||
|
|
8fdb468ad3 | ||
|
|
af52e4a1bf | ||
|
|
b24eb731ce | ||
|
|
d81967e6a1 | ||
|
|
c81db17588 | ||
|
|
3c10d915a5 | ||
|
|
3ce006d6b4 | ||
|
|
cc4cbd52e4 | ||
|
|
1f4c4a4d82 | ||
|
|
75c1dccfd2 | ||
|
|
7094bfdf7c | ||
|
|
fead3d5b26 | ||
|
|
cd5bae7518 | ||
|
|
78d436f847 | ||
|
|
0d3d47500c | ||
|
|
768d33c618 | ||
|
|
6ffbdbadf9 | ||
|
|
b0e47fc4ed | ||
|
|
5693b4488b | ||
|
|
6522fcb28b | ||
|
|
7cecf79515 | ||
|
|
e90bcde512 | ||
|
|
9c32b1b1da | ||
|
|
9f8fc6f2eb | ||
|
|
b034fe5335 | ||
|
|
90af6b8515 | ||
|
|
99ff732d67 | ||
|
|
d5d28e92c8 | ||
|
|
93ef9228aa | ||
|
|
bcad05e032 | ||
|
|
dbc3044a0f | ||
|
|
1fc7b55e3b | ||
|
|
bcb8fde986 | ||
|
|
a20ce96744 | ||
|
|
55fd16d53c | ||
|
|
1385741f2d | ||
|
|
05126ae73a | ||
|
|
3a6a216b9d | ||
|
|
edac9a987a | ||
|
|
8169ef253d | ||
|
|
f2535ce335 | ||
|
|
831f0b27a9 | ||
|
|
f08c4c231f | ||
|
|
b505e45c6f | ||
|
|
3de6655f26 | ||
|
|
6e5093858b | ||
|
|
b6b00ab96e | ||
|
|
e49e8e6710 | ||
|
|
1354fced46 | ||
|
|
a0c9349e08 | ||
|
|
644a7eb33d | ||
|
|
c019224e96 | ||
|
|
05f3f49e0c | ||
|
|
79189ef2a4 | ||
|
|
d717ecd0c2 | ||
|
|
60ddb475d7 | ||
|
|
e926649787 | ||
|
|
48d45ac7f8 | ||
|
|
7fd8ae4629 | ||
|
|
eec3438de9 | ||
|
|
200ee8931d | ||
|
|
10122312cb | ||
|
|
ce1de3d5b4 | ||
|
|
fbbaa0ac5e | ||
|
|
a4ad4cba28 | ||
|
|
5538e0c575 | ||
|
|
11b6c23000 | ||
|
|
ba21ff00bc | ||
|
|
65a96922f6 | ||
|
|
92bc72f51b | ||
|
|
fdaa249f44 | ||
|
|
9c51fff0ef | ||
|
|
5299e0ba06 | ||
|
|
25077e9ded | ||
|
|
d2dd46bfdf | ||
|
|
8de9978213 | ||
|
|
293636a5f8 | ||
|
|
1e8a261ada | ||
|
|
f1ae501d49 | ||
|
|
9d740f9f47 | ||
|
|
76dd4bf69a | ||
|
|
fed8c7344e | ||
|
|
a770f3695c | ||
|
|
7abd260f98 | ||
|
|
fbd77a5334 | ||
|
|
50282f2ddb | ||
|
|
d61308578c | ||
|
|
171974bd41 | ||
|
|
b5f228351b | ||
|
|
f1734a2de0 | ||
|
|
ecf6dc03df | ||
|
|
283bf067c5 | ||
|
|
3467322e99 | ||
|
|
101087613e | ||
|
|
70dae4b701 | ||
|
|
a36f0d5184 | ||
|
|
b6b0d3d343 | ||
|
|
5cea685405 | ||
|
|
b8f16a6382 | ||
|
|
12847cf071 | ||
|
|
a639bca702 | ||
|
|
586a41ad49 | ||
|
|
6d85bce2a0 | ||
|
|
97cbf283bc | ||
|
|
d124ce3b36 | ||
|
|
9f9b3643ac | ||
|
|
c69e5a80b2 | ||
|
|
86aa1a05e9 | ||
|
|
0fa5da8abf | ||
|
|
7771a24b9c | ||
|
|
4f43221819 | ||
|
|
be86000fe0 | ||
|
|
c9eed6dae7 | ||
|
|
d3aed4c283 | ||
|
|
58c1571669 | ||
|
|
c73817fb6a | ||
|
|
208c416554 | ||
|
|
74a0c5ffe4 | ||
|
|
199b57d9f9 | ||
|
|
a556be4c57 | ||
|
|
bbda9d44ca | ||
|
|
a1e1880a8c | ||
|
|
a28de96885 | ||
|
|
aae48dc401 | ||
|
|
37c6e50cb6 | ||
|
|
287e4fa3c2 | ||
|
|
fea3a1c04e | ||
|
|
8674eee714 | ||
|
|
74e5d4eae0 | ||
|
|
48599c207d | ||
|
|
24636c9a87 | ||
|
|
11f56cc54a | ||
|
|
2d31e5a8b9 | ||
|
|
cfd8f06435 | ||
|
|
a6a111db8a | ||
|
|
ca19dbaefb | ||
|
|
8418e0cb1d | ||
|
|
d301fc2b4d | ||
|
|
fbcde41321 | ||
|
|
53838036e6 | ||
|
|
b3b9755aa7 | ||
|
|
82a45b80b4 | ||
|
|
a8194d73e7 | ||
|
|
1dccf61f62 | ||
|
|
09f31ce6da |
No files matched your search
@@ -0,0 +1,172 @@
|
||||
---
|
||||
name: dc-cypress
|
||||
description: >
|
||||
Use this skill when writing or debugging Cypress E2E specs in the dc repo, or when working on the
|
||||
JS mock backend they run against. Covers the SASjs Server mock environment (deploy, seed, ports),
|
||||
the spec conventions the CI harness expects, the viewer/editor DOM selectors that the existing
|
||||
specs get wrong, the fixed 1280x720 video capture, and the rule that a mock service must mirror
|
||||
the real SAS service it stands in for.
|
||||
---
|
||||
|
||||
# Cypress E2E and the JS mock backend
|
||||
|
||||
The specs under `client/cypress/e2e/` run against a mock backend: JS stored programs under `sas/mocks/sasjs/services/` executed by SASjs Server in JS-only mode. No SAS licence, no SAS server. CI (`.gitea/workflows/build.yaml`) builds the same environment - SASjs Server on :5000, `sasjs cbd -t server-ci`, makedata, then `ng serve` on :4200 - and runs a fixed list of specs against it.
|
||||
|
||||
This skill covers the parts that are not obvious from reading a spec file: how to stand the environment up, the selectors that actually work, and the traps in the mock services. It does not cover the SAS-side tests (see `.agents/docs/testing.md` and the `dc-sas` skill).
|
||||
|
||||
## When to Use
|
||||
|
||||
- Adding or changing a spec under `client/cypress/e2e/`.
|
||||
- A mock service needs a new behaviour, or a spec "does nothing" because the mock ignores an input.
|
||||
- Recording a demo video from a spec run.
|
||||
- Debugging a spec that passes locally but fails in CI (usually a viewport or timing assumption).
|
||||
|
||||
Don't use for: SAS backend tests (`sasjs test -t 4gl`), Angular unit tests (`npm run test:headless`), or general SAS development (use the `dc-sas` skill).
|
||||
|
||||
## Stand up the mock environment
|
||||
|
||||
1. **SASjs Server, JS mode.** Download the linux build from the sasjs/server releases, then create a `.env` next to the binary:
|
||||
|
||||
RUN_TIMES=js
|
||||
NODE_PATH=node
|
||||
CORS=enable
|
||||
WHITELIST=http://localhost:4200
|
||||
|
||||
Start it (`./api-linux`). It runs in desktop mode on :5000 with no authentication.
|
||||
|
||||
2. **Deploy the mocks.**
|
||||
|
||||
cd sas/mocks/sasjs && sasjs cbd -t server-ci
|
||||
|
||||
To start from a clean appLoc, delete it first:
|
||||
|
||||
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
|
||||
|
||||
3. **Seed the mock database.**
|
||||
|
||||
cd sas/mocks && sasjs request services/admin/makedata -t server-ci \
|
||||
-d deploy/makedata.json -o ./makedata_out.json
|
||||
|
||||
`makedata` replies with HTML (it is normally called as a URL redirect), so the CLI reports `invalid Json string`. That is expected - check the drive instead.
|
||||
|
||||
4. **Frontend.** Point the `sasjs` tag in `client/src/index.html` at the mock backend (`serverUrl="http://localhost:5000"`, `appLoc="/Public/app/dc"`, `serverType="SASJS"` - a local-only edit, the repo default is `serverUrl=""`), then `npx ng serve --host 0.0.0.0 --port 4200`. `client/cypress.env.json` needs `username`/`password` entries; any values work, `loginAndUpdateValidKey()` no-ops when there is no login form.
|
||||
|
||||
5. **Run.**
|
||||
|
||||
cd client && npx cypress run --browser electron --spec cypress/e2e/<spec>.cy.ts
|
||||
|
||||
Only Electron is installed here (no system Chrome), so always pass `--browser electron`.
|
||||
|
||||
## Spec conventions
|
||||
|
||||
- Start the file with `export {}` so its top-level consts do not collide with other spec files under the TS type-checker.
|
||||
- `beforeAll`: `cy.visit(\`${hostUrl}/SASLogon/logout\`)` then `cy.loginAndUpdateValidKey(true)`. The `true` forces a licence key instead of relying on an earlier spec in the same run having applied one.
|
||||
- `beforeEach`: `cy.visit(hostUrl + appLocation)`, `visitPage('home')`, `visitPage('view/data')`. The `home` visit is what triggers the startup-data fetch; going straight to `view/data` leaves the nav tree empty in slower environments.
|
||||
- Declare helpers at the **bottom of each spec file** (that is the house pattern) - do not introduce a shared helper module.
|
||||
- Use `Cypress.env('longerCommandTimeout')` for waits and gate on `.app-loading` disappearing.
|
||||
- **No artificial delays.** These specs are the CI regression suite, so `cy.wait(n)` to "let a result land" is not acceptable - assert on the resulting state instead (Cypress retries assertions). Replace a sleep with a state assertion: e.g. after picking an option from a soft-select, wait for `#datalist_<inputId>` to have class `hidden` rather than sleeping.
|
||||
- Lint: `cd client && npm run lint:check` (prettier covers `cypress/e2e/*.cy.ts`).
|
||||
|
||||
## Selectors that work
|
||||
|
||||
- Viewer search box: `input[name="search-input"]` (Enter or the magnifier icon submits); the numeric toggle is `input[name="numeric_check"]`.
|
||||
- Row/column count in the header: `.title-col p` renders `(1,000 rows, 9 cols)` - and `(1 row, 9 cols)` when the result is a single row, so an assertion on the count has to pluralise.
|
||||
- Filter info bar: `.infoBar b` - only rendered when the backend's `sasparams.FILTER_TEXT` is neither blank, `' '`, nor `'1=1'`.
|
||||
- Empty result: `.noData h3` with `No data found with given conditions`.
|
||||
- Grid: `#hotTable .ht_master.handsontable .htCore tbody tr` (body rows), `#hotTable .ht_clone_top .htCore thead` (headers, and `button.changeType` confirms Handsontable has finished rendering them).
|
||||
- Nav tree: `.nav-tree clr-tree > clr-tree-node` -> `.clr-tree-node-content-container .clr-treenode-content p` (expands a library) -> `.clr-treenode-link` (table links).
|
||||
- **Viewer filter modal** - `.filterSide` (the options button) -> `.dropdown-menu` contains `Filter` -> `#vals_var_id0_0` (variable, pick from `#datalist_vals_var_id0_0 option`) and `#vals_0_0` (value) -> `.filter-modal button[aria-label="Ok"]`.
|
||||
- `filtering.cy.ts` opens the filter with `.btnCtrl .btnView`, which is an **editor** control and does not exist in the viewer. Do not copy that helper into a viewer spec.
|
||||
- The soft-select inputs drop a transparent click-catcher over the modal while their suggestion list is open, so Cypress reports the Ok button (and the next input) as covered by `.overlay`. Send the Escape keyup a user would send (`trigger('keyup', { key: 'Escape', force: true })`) and wait for the list to close before clicking on.
|
||||
|
||||
## Recording a demo video
|
||||
|
||||
`video: true` in `cypress.config.ts` is all that is needed. Run the spec and the MP4 lands in `client/cypress/videos/<spec>.mp4`.
|
||||
|
||||
**The capture is the browser window's content area, and it contains the Cypress runner** - the command log takes the left ~450px and the app is rendered in the remaining pane, so the pane is `(capture width - 450) x (capture height - 96)` and the app is zoomed to fit it.
|
||||
|
||||
That makes the window size the only lever that matters:
|
||||
|
||||
- Electron is stuck at 1280x720 (it ignores `--window-size`), i.e. an 830x624 pane, and the repo's 1600x900 viewport renders at 50% - the app only occupies ~806x452 of it.
|
||||
- A real Chromium browser honours `--window-size`, so the capture can be sized to whatever the pane needs. `cypress.config.ts` passes it through from `RECORD_WINDOW_SIZE` (inert in CI, which sets no env).
|
||||
|
||||
For a 16:9 pane, solve `(W - 450) / (H - 96) = 16/9`: W = 1920 gives H = 923 of window *content*, and Chrome adds ~87px of window chrome above that, so pass 1920x1010:
|
||||
|
||||
RECORD_WINDOW_SIZE=1920,1010 npx cypress run \
|
||||
--browser /opt/data/profiles/dc/tools/chrome-linux64/chrome \
|
||||
--spec cypress/e2e/full-table-search.cy.ts \
|
||||
--env skipNegative=true,demoPause=1500
|
||||
|
||||
That yields a 1920x922 capture with a 1470x826 pane (16:9) and, at the repo's 1600x900 viewport, an app at ~0.90 zoom occupying 1441x812 - about 1.9x the linear size of the 16:9 crop Electron can give. Crop the app out:
|
||||
|
||||
ffmpeg -ss <start> -i cypress/videos/<spec>.mp4 -vf "crop=1440:812:464:80" \
|
||||
-an -c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p \
|
||||
-movflags +faststart <spec>-cut.mp4
|
||||
|
||||
Measure the crop rect from the frames rather than guessing: the pane's flat background is rgb(225,227,236), so a row/column scan for pixels that differ from it gives the app's bounding box (for the 1920x922 capture: runner pane ends x=449, app x=463-1903, app y=80-893; for the 1280x720 Electron capture: app x=468-1259, y=80-675). The app's own background is nearly the same grey, so verify the result on the first, middle and last frames rather than trusting a pixel test alone.
|
||||
|
||||
To find where to cut, scan the mean *and* standard deviation of the content area: the app boots mid-run (a dark screen, mean ~65, sd ~2.5), the "Loading Table Viewer" spinner is flat and light (mean ~252, sd ~0), and the rendered table is mean ~245, sd ~31. Cut on the first table frame - cutting a fraction early shows the boot screen or the spinner.
|
||||
|
||||
Cypress starts its own Xvfb for the run (a second display appears in `/tmp/.X11-unix`), so an external `DISPLAY` is ignored; `--force-device-scale-factor` and a CSS `zoom` also do not change the capture.
|
||||
|
||||
### Grid columns that are clipped
|
||||
|
||||
Handsontable sizes columns to content, so a narrow result can be *wider* than the grid viewport: the horizontal scrollbar appears and the rightmost column (NOTES on `MPE_X_SEARCH`) is cut off. The cell text is still in the DOM, so an assertion passes either way - the column only has to be scrolled into view for the *video* to show why the rows matched.
|
||||
|
||||
const scrollGridTo = (position: 'left' | 'right') => {
|
||||
cy.get('#hotTable .wtHolder').first().then(($holder: any) => {
|
||||
const el = $holder[0]
|
||||
el.scrollLeft = position === 'right' ? el.scrollWidth : 0
|
||||
expect(el.scrollLeft).to.equal(position === 'right' ? el.scrollWidth - el.clientWidth : 0)
|
||||
})
|
||||
}
|
||||
|
||||
Setting `scrollLeft` is enough - Handsontable keeps the header clone in step - and the browser clamps it to `scrollWidth - clientWidth`, so that is what scrolling right has to land on. This is a legitimate spec step (it runs in CI), not a recording flag.
|
||||
|
||||
### A longer, more readable demo
|
||||
|
||||
A spec that doubles as a walkthrough should be a single `it()`: open the table once and make each variation a search in place. The recording then reads as a demo rather than a sequence of page loads, and it is far faster - every extra `it()` pays for the `beforeEach` navigation again.
|
||||
|
||||
Two recording-only env flags, both inert in CI because CI passes no env:
|
||||
|
||||
const skipNegative = `${Cypress.env('skipNegative')}` === 'true'
|
||||
const negativeStep = (step: () => void) => {
|
||||
if (!skipNegative) step()
|
||||
}
|
||||
const demoPauseMs = Number(Cypress.env('demoPause')) || 0
|
||||
|
||||
negativeStep(() => {
|
||||
searchFor('trench')
|
||||
assertNoData()
|
||||
})
|
||||
|
||||
- `skipNegative=true` drops the steps that legitimately match nothing (wrong case, partial number, value not present), so the recording never shows an empty screen.
|
||||
- `demoPause=1500` pauses 1.5s at each row-count assertion, so each result can be read. Apply it in one place (the row-count assertion, which every beat makes exactly once) rather than sprinkling `cy.wait()` through the test - CI still runs the file with no waits at all.
|
||||
|
||||
More beats, not a slower take, is what makes a demo longer: a walkthrough of 12 row-count beats takes 52s against 30s for 7.
|
||||
|
||||
## Mock services
|
||||
|
||||
- One JS file per service under `sas/mocks/sasjs/services/**`, deployed as SASjs stored programs. Shared helpers live in `sas/mocks/sasjs/services/dcMockUtils.js` (`fetchTable`/`parseCsv` for `%webout(FETCH)`, `webOutOpen`/`webOutObj`/`webOutClose` for `%webout`, `mpeFilterMaster`, `mpFilterGenerate`, `mpFilterStore`, `getDdType`, `formatSasValue`, `sasVarsEntry`).
|
||||
- Mock data is one JSON file per table at `<drive>/files/<appLoc>/data/<LIBREF>/<table>.json`, written by `services/admin/makedata.js` (which also writes `services/settings.js`). The drive lives under the SASjs Server directory (`sasjs_root/drive`).
|
||||
- `makedata.js` self-destructs after a successful run (it deletes itself from the drive so the frontend can detect completion), so re-deploy before re-seeding.
|
||||
- **A mock must mirror the real service.** `viewdata.js` once ignored `FILTER_RK` and hard-coded `FILTER_TEXT` to blank, so the viewer's filter silently filtered nothing and its info bar never showed a clause, while the real `viewdata.sas` applies `%mpe_filtermaster` and then `%mp_searchdata`. If a mock is a deliberate stub, say so in a comment and make the divergence explicit - a mock that quietly drops an input turns an E2E spec into a test of nothing.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- `sasjs cbd -t server-ci` rebuilds the *mock services* and re-deploys the streamed web app from `client/dist`; it does not rebuild the Angular app.
|
||||
- Deleting the drive folder before deploying avoids stale services and stale data.
|
||||
- Captured `_webout` JSON, request logs and iteration snapshots belong in `tmp/` (gitignored), never in `sas/mocks/` - see the `dc-sas` skill.
|
||||
- The mock server has no auth, so `cy.visit` on `/SASLogon/logout` returns 404 - that is fine, Cypress does not fail on the status code.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx cypress run --browser electron --spec <specs>` - all green, and run it more than once when you have just removed a wait.
|
||||
- `cd client && npm run lint:check` passes.
|
||||
- For a mock change, hit the service directly before writing UI assertions:
|
||||
|
||||
curl -sS -H "Content-Type: application/json" -X POST \
|
||||
--data '{"_program":"/Public/app/dc/services/public/viewdata","SASControlTable":"LIBDS:$41. FILTER_RK:best. SEARCHTYPE:$4 SEARCHVAL:$1000\nDC_JSLIB.MPE_X_SEARCH,0,CHAR,Trench"}' \
|
||||
http://localhost:5000/SASjsApi/stp/execute
|
||||
|
||||
then check `sasparams[0].NOBS`, the row count and `FILTER_TEXT`. Pin those exact numbers in the spec.
|
||||
+36
-11
@@ -3,6 +3,42 @@
|
||||
# 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
|
||||
|
||||
## 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
|
||||
@@ -10,14 +46,3 @@ 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
|
||||
)
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/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
|
||||
@@ -15,6 +15,22 @@ jobs:
|
||||
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 client/.npmrc is written below, because that file is
|
||||
# created from the NPMRC secret and a later scan would flag the
|
||||
# registry token this job injects itself.
|
||||
# --no-git scans the working tree, which is what a pull request gate
|
||||
# is about.
|
||||
run: |
|
||||
npm ci
|
||||
node_modules/.bin/gitleaks detect --no-git --source . --redact --no-banner -v
|
||||
|
||||
- name: Install Google Chrome
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -107,6 +123,15 @@ jobs:
|
||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||
npm ci
|
||||
|
||||
- name: Build frontend for web streaming
|
||||
# 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.
|
||||
run: |
|
||||
cd client
|
||||
npm run build
|
||||
|
||||
- name: Setup and start SASjs server
|
||||
run: |
|
||||
npm i -g pm2
|
||||
@@ -117,8 +142,27 @@ jobs:
|
||||
echo NODE_PATH=node >> .env
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .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 api-linux --wait-ready
|
||||
# `pm2 start --wait-ready` can return while the app is still
|
||||
# launching (it does not always wait for the ready signal), 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 api-linux --lines 100 --nostream
|
||||
exit 1
|
||||
|
||||
- name: Deploy mocked services
|
||||
run: |
|
||||
@@ -154,7 +198,7 @@ jobs:
|
||||
# Start frontend and run cypress
|
||||
# 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)
|
||||
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"
|
||||
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/hook-programs.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/filter-panel.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/full-table-search.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts,cypress/e2e/browser-info.cy.ts,cypress/e2e/ddl-export.cy.ts,cypress/e2e/row-cell-limits.cy.ts"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
|
||||
@@ -33,10 +33,29 @@ jobs:
|
||||
echo NODE_PATH=node >> .env
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .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
|
||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||
unzip linux.zip
|
||||
pm2 start api-linux --wait-ready
|
||||
# `pm2 start --wait-ready` can return while the app is still
|
||||
# launching (it does not always wait for the ready signal), 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 api-linux --lines 100 --nostream
|
||||
exit 1
|
||||
|
||||
- name: Write .npmrc file
|
||||
run: echo "$NPMRC" > client/.npmrc
|
||||
|
||||
@@ -117,8 +117,27 @@ jobs:
|
||||
echo NODE_PATH=node >> .env
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .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 api-linux --wait-ready
|
||||
# `pm2 start --wait-ready` can return while the app is still
|
||||
# launching (it does not always wait for the ready signal), 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 api-linux --lines 100 --nostream
|
||||
exit 1
|
||||
|
||||
- name: Deploy mocked services
|
||||
run: |
|
||||
@@ -302,8 +321,10 @@ jobs:
|
||||
run: |
|
||||
cd sas
|
||||
cp sasjsbuild/viya.json ../client/dist/viya.json
|
||||
cd ..
|
||||
zip -r frontend.zip ./client/dist
|
||||
# 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: Release Typedoc
|
||||
run: |
|
||||
@@ -336,23 +357,43 @@ jobs:
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
||||
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
|
||||
|
||||
# Update body (also confirms the token has contents:write on this repo)
|
||||
# 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.
|
||||
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
|
||||
NEW_BODY=$(jq -n --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
|
||||
'$body + "\n" + $verify + "\n\nFor installation instructions, please visit https://docs.datacontroller.io/"')
|
||||
curl -k --fail-with-body -sS -X PATCH \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(jq -n --arg body "$RELEASE_BODY"$'\n\nFor installation instructions, please visit https://docs.datacontroller.io/' \
|
||||
'{draft:false, body:$body}')" \
|
||||
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
|
||||
"$BASE/releases/$RELEASE_ID"
|
||||
|
||||
# Upload assets
|
||||
URL="$BASE/releases/$RELEASE_ID/assets"
|
||||
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
|
||||
for f in "${ASSETS[@]}"; do
|
||||
echo "Uploading $f ..."
|
||||
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
|
||||
done
|
||||
echo "Uploading SHA256SUMS ..."
|
||||
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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
|
||||
+107
@@ -1,3 +1,110 @@
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
+22
-8
@@ -4,6 +4,23 @@
|
||||
## 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)
|
||||
@@ -33,15 +50,12 @@ npm i
|
||||
|
||||
[Handsontable](https://www.npmjs.com/package/handsontable)
|
||||
|
||||
Licence should be inserted in the `client/src/index.html` file:
|
||||
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.
|
||||
|
||||
```
|
||||
<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.
|
||||
|
||||
@@ -38,9 +38,24 @@ export default defineConfig({
|
||||
// Pin the browser locale so locale-formatted cells (intl-date/time/datetime)
|
||||
// render deterministically regardless of the runner's system locale.
|
||||
on('before:browser:launch', (browser, launchOptions) => {
|
||||
// Recording only: the video capture is the browser window, which
|
||||
// contains the Cypress runner - the command log takes the left ~450px
|
||||
// and the app sits in the remaining pane. Sizing the window is the
|
||||
// only lever that moves the capture, and only for a real Chromium
|
||||
// browser (Electron ignores it). For a 16:9 pane, solve
|
||||
// (W - 450) / (H - 96) = 16/9: W = 1920 -> H = 923 of window content,
|
||||
// so pass RECORD_WINDOW_SIZE=1920,1010 and crop the pane.
|
||||
// Inert unless the env var is set, so CI is unaffected.
|
||||
if (process.env.RECORD_WINDOW_SIZE && browser.family === 'chromium') {
|
||||
launchOptions.args.push(
|
||||
`--window-size=${process.env.RECORD_WINDOW_SIZE}`
|
||||
)
|
||||
}
|
||||
|
||||
if (browser.family === 'chromium' && browser.name !== 'electron') {
|
||||
launchOptions.args.push('--lang=en-GB')
|
||||
}
|
||||
|
||||
return launchOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
// 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)
|
||||
|
||||
// The mock estate carries a valid licence key (mock-storage/licence.json),
|
||||
// so the app activates directly with no free-tier banner 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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
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}`)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
// 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/makedata.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')
|
||||
|
||||
cy.get('.filter-modal button[aria-label="Ok"]').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')
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// The EDIT screen refuses a selection that is too large - DC_MAXOBS_WEBEDIT
|
||||
// rows, or DC_MAXCELLS_WEBEDIT cells (rows x columns), whichever is reached
|
||||
// first - and the VIEW screen caps at DC_MAXOBS_WEBVIEW rows.
|
||||
//
|
||||
// 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,400 cells), under the row limit
|
||||
// but over the CELL limit
|
||||
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 refuses a table over the row limit', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
||||
openEdit()
|
||||
|
||||
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'Table is too big (1000 rows) - please filter and try again!'
|
||||
)
|
||||
})
|
||||
|
||||
it('3 | EDIT refuses a table over the cell limit', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_wide')
|
||||
openEdit()
|
||||
|
||||
// 200 rows x 1002 columns (the 1001 stored columns plus the housekeeping
|
||||
// delete column) = 200,400 cells, over the 200,000 cell limit, while the
|
||||
// row count is under the 250 row limit.
|
||||
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'Selection is too wide (200 rows x 1002 cols) - please filter and try again!'
|
||||
)
|
||||
})
|
||||
|
||||
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 18 columns is 126 cells, so
|
||||
// at 110 the TESTDATA.MPE_X_NEW edit must be refused, 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 17 x 6 = 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('.abortMsg', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'Selection is too wide (7 rows x 18 cols) - please filter and try again!'
|
||||
)
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
||||
// 126 cells under a 200000 cell limit: the editor loads, no abort.
|
||||
cy.get('#hotTable .ht_master .htCore tbody tr', {
|
||||
timeout: longerCommandTimeout
|
||||
}).should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('5 | the filter picker still lists the variables after the abort', () => {
|
||||
openTableFromTree(libraryToOpenIncludes, 'mpe_x_search')
|
||||
openEdit()
|
||||
|
||||
cy.get('.abortMsg', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
'Table is too big (1000 rows) - please filter and try again!'
|
||||
)
|
||||
|
||||
closeAbortModal()
|
||||
|
||||
// The abort returns no table, and the picker's variable list normally comes
|
||||
// from the table's own columns - so without the public/getcols fallback the
|
||||
// "please filter and try again" advice has nothing to pick from. Every
|
||||
// column of MPE_X_SEARCH must be on offer.
|
||||
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'
|
||||
])
|
||||
})
|
||||
|
||||
// and the suggestion list is usable, not just populated
|
||||
setFilterVariable('SITE_NAME')
|
||||
|
||||
// Submitting is what exercises TYPE. The picker keys its operator set and
|
||||
// its value quoting on it, and the fallback list never passes through
|
||||
// mergeColsRules, so without TYPE on this payload a character value would be
|
||||
// submitted unquoted and the backend's %mp_filtercheck would reject it.
|
||||
setFilterValue('Bristol')
|
||||
submitFilter()
|
||||
|
||||
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
||||
'contain.text',
|
||||
"SITE_NAME = 'Bristol'"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 abort modal offers Close, and the filter button sits behind it. */
|
||||
const closeAbortModal = () => {
|
||||
cy.get('app-info-modal', { timeout: longerCommandTimeout })
|
||||
.contains('button', 'Close')
|
||||
.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')
|
||||
}
|
||||
@@ -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@^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'
|
||||
'@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@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;handsontable@18.1.1;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) {
|
||||
|
||||
Generated
+271
-469
File diff suppressed because it is too large.
Load diff
+14
-14
@@ -41,21 +41,21 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.3.27",
|
||||
"@angular/animations": "20.3.33",
|
||||
"@angular/cdk": "^20.2.14",
|
||||
"@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",
|
||||
"@angular/common": "20.3.33",
|
||||
"@angular/compiler": "20.3.33",
|
||||
"@angular/core": "20.3.33",
|
||||
"@angular/forms": "20.3.33",
|
||||
"@angular/platform-browser": "20.3.33",
|
||||
"@angular/platform-browser-dynamic": "20.3.33",
|
||||
"@angular/router": "20.3.33",
|
||||
"@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.0.0",
|
||||
"@sasjs/adapter": "^4.18.0",
|
||||
"@handsontable/angular-wrapper": "18.1.1",
|
||||
"@sasjs/adapter": "4.19.1",
|
||||
"@sasjs/utils": "^3.6.0",
|
||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||
"@types/d3-graphviz": "^2.6.7",
|
||||
@@ -69,7 +69,7 @@
|
||||
"d3-graphviz": "^5.0.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"fs-extra": "^7.0.1",
|
||||
"handsontable": "18.0.0",
|
||||
"handsontable": "18.1.1",
|
||||
"https-browserify": "1.0.0",
|
||||
"hyperformula": "^2.5.0",
|
||||
"iconv-lite": "^0.5.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.32",
|
||||
"@angular/compiler-cli": "^20.3.27",
|
||||
"@angular/cli": "20.3.37",
|
||||
"@angular/compiler-cli": "20.3.33",
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"@compodoc/compodoc": "^2.0.0",
|
||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||
@@ -148,7 +148,7 @@
|
||||
"ajv": "8.18.0",
|
||||
"uuid": "11.1.1",
|
||||
"lighthouse": "13.4.0",
|
||||
"fast-uri": "3.1.7",
|
||||
"fast-uri": "3.1.8",
|
||||
"readdir-glob": {
|
||||
"brace-expansion": "^5.0.9"
|
||||
},
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
[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] .pika-single .pika-next {
|
||||
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after {
|
||||
width: var(--ht-icon-size);
|
||||
height: var(--ht-icon-size);
|
||||
-webkit-mask-size: contain;
|
||||
@@ -12,14 +11,6 @@
|
||||
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);
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
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') {
|
||||
@@ -346,6 +347,18 @@
|
||||
@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>
|
||||
|
||||
@@ -20,6 +20,8 @@ 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'
|
||||
@@ -190,40 +192,45 @@ 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') || '',
|
||||
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
||||
contextName: getAppAttribute('contextName') || ''
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DeployComponent } from './deploy.component'
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', component: DeployComponent },
|
||||
{ path: 'manualdeploy', component: DeployComponent }
|
||||
]
|
||||
const routes: Routes = [{ path: '', component: DeployComponent }]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
|
||||
@@ -29,20 +29,6 @@
|
||||
></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>
|
||||
}
|
||||
@@ -52,29 +38,17 @@
|
||||
@if (step > 0) {
|
||||
@switch (true) {
|
||||
@case (sasJsConfig.serverType === ServerType.SasViya) {
|
||||
@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>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
@case (sasJsConfig.serverType === ServerType.Sasjs) {
|
||||
<div class="autodeploy-section card">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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'
|
||||
@@ -19,17 +17,11 @@ import { DcAdapterSettings } from '../models/DcAdapterSettings'
|
||||
})
|
||||
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()
|
||||
|
||||
@@ -39,22 +31,13 @@ export class DeployComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private appStoreService: AppStoreService,
|
||||
private sasService: SasService,
|
||||
private loggerService: LoggerService,
|
||||
private router: Router
|
||||
private sasService: SasService
|
||||
) {
|
||||
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() {
|
||||
@@ -66,54 +49,16 @@ export class DeployComponent implements OnInit {
|
||||
*/
|
||||
public setDeployDefaults() {
|
||||
this.dcPath = this.dcAdapterSettings?.dcPath || ''
|
||||
this.selectedAdminGroup = this.dcAdapterSettings?.adminGroup || ''
|
||||
if (!this.selectedAdminGroup) {
|
||||
this.selectedAdminGroup = 'SASAdministrators'
|
||||
}
|
||||
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() {
|
||||
window.open(location.href.split('#')[0], '_blank')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 { ClarityModule } from '@clr/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
@@ -12,7 +11,6 @@ import { DeployRoutingModule } from './deploy-routing.module'
|
||||
declarations: [
|
||||
DeployComponent,
|
||||
AutomaticComponent,
|
||||
ManualComponent,
|
||||
SasjsConfiguratorComponent
|
||||
],
|
||||
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
<div class="card-header position-relative">
|
||||
Configurator
|
||||
<p class="d-inline-block ml-10 mb-10 mt-0">App Location: {{ appLoc }}</p>
|
||||
<button
|
||||
(click)="deleteKeys()"
|
||||
class="btn btn-primary clear-memory-button position-absolute"
|
||||
>
|
||||
Clear memory
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-block">
|
||||
<!-- <div class="card-title">
|
||||
Client Details
|
||||
</div> -->
|
||||
|
||||
@if (needsLogin) {
|
||||
<div id="loginForm" class="d-none">
|
||||
<p class="mb-10">Please log in first</p>
|
||||
<label for="username" class="clr-control-label">Username</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<input type="text" id="username" class="clr-input" />
|
||||
</div>
|
||||
</div>
|
||||
<label for="password" class="clr-control-label">Password</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<input type="password" id="password" class="clr-input" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary d-none" id="loginBtn">Log in</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!needsLogin) {
|
||||
<form>
|
||||
<div class="clr-form-control">
|
||||
<label for="select-full" class="clr-control-label">Admin group</label>
|
||||
<div class="clr-control-container">
|
||||
<div class="clr-select-wrapper">
|
||||
<select
|
||||
[(ngModel)]="selectedAdminGroup"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
name="selectedAdminGroup"
|
||||
id="adminGroupsSelect"
|
||||
class="clr-select"
|
||||
>
|
||||
@for (adminGroup of adminGroups; track adminGroup) {
|
||||
<option [value]="adminGroup.id">
|
||||
{{ adminGroup.name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clr-form-control">
|
||||
<div [class.hidden]="contextsLoading">
|
||||
<label for="select-full" class="clr-control-label">Context</label>
|
||||
<div class="clr-control-container">
|
||||
<div class="clr-select-wrapper">
|
||||
<select
|
||||
[(ngModel)]="selectedContext"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
name="selectedContext"
|
||||
class="clr-select"
|
||||
>
|
||||
@for (context of allContexts; track context) {
|
||||
<option [value]="context.name">
|
||||
{{ context.name }}
|
||||
@if ((context.attributes | json) != '{}') {
|
||||
<span>( {{ context.attributes.sysUserId }} )</span>
|
||||
}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
(click)="executableContext()"
|
||||
type="button"
|
||||
class="btn btn-icon"
|
||||
id="contexts-btn"
|
||||
>
|
||||
<clr-icon shape="play"></clr-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div [class.hidden]="!contextsLoading" class="d-flex">
|
||||
<span class="spinner spinner-inline mr-10">
|
||||
Loading contexts...
|
||||
</span>
|
||||
<span> Loading contexts... </span>
|
||||
</div>
|
||||
<label for="dcloc" class="mt-20 clr-control-label">DC Loc</label>
|
||||
<div class="mb-10 clr-control-container">
|
||||
<div class="clr-input-wrapper">
|
||||
<input
|
||||
[(ngModel)]="dcPath"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(focusout)="saveDcPath()"
|
||||
name="dcPath"
|
||||
type="text"
|
||||
id="dcloc"
|
||||
class="clr-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-10">
|
||||
Select JSON file to upload (json build file preloaded):
|
||||
</p>
|
||||
<div class="d-flex flex-column">
|
||||
<input
|
||||
type="file"
|
||||
(click)="clearUploadInput($event)"
|
||||
(change)="onJsonFileChange($event)"
|
||||
/>
|
||||
<!-- <button *ngIf="downloadFileBtn" (click)="downloadSasPrecodeFile()" style="width: 40px; min-width: 0;" class="btn btn-sm btn-icon">
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button> -->
|
||||
</div>
|
||||
<div class="mt-20 d-flex align-items-center">
|
||||
<button
|
||||
(click)="executeJson()"
|
||||
class="btn btn-primary"
|
||||
[clrLoading]="isSubmittingJson"
|
||||
id=""
|
||||
class="mt-0"
|
||||
>
|
||||
SUBMIT JSON
|
||||
</button>
|
||||
@if (isJsonSubmitted) {
|
||||
<span>JSON Submitted Successfully</span>
|
||||
}
|
||||
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
||||
Loading...
|
||||
</span> -->
|
||||
</div>
|
||||
<p class="mt-10">Select SAS file to upload:</p>
|
||||
<div class="d-flex flex-column">
|
||||
<input
|
||||
type="file"
|
||||
(click)="clearUploadInput($event)"
|
||||
(change)="onSasFileChange($event); downloadFileBtn = true"
|
||||
/>
|
||||
@if (downloadFileBtn) {
|
||||
<button
|
||||
(click)="downloadSasPrecodeFile()"
|
||||
class="btn btn-sm btn-icon min-w-0 w-40"
|
||||
>
|
||||
<clr-icon shape="download"></clr-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-20 d-flex align-items-center">
|
||||
<button
|
||||
(click)="executeSAS()"
|
||||
class="btn btn-primary"
|
||||
[clrLoading]="executingScript"
|
||||
id="deploy"
|
||||
class="mt-0"
|
||||
>
|
||||
SUBMIT
|
||||
</button>
|
||||
<!-- <span *ngIf="executingScript" class="spinner spinner-inline ml-3">
|
||||
Loading...
|
||||
</span> -->
|
||||
</div>
|
||||
</div>
|
||||
@if (jobLog.length > 0) {
|
||||
<p class="mb-0 mt-10">File execute completed</p>
|
||||
<hr />
|
||||
<div>
|
||||
<button
|
||||
(click)="downloadFile(jobLog, 'execute-script-log', 'txt')"
|
||||
class="btn btn-primary mt-0 mr-20"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
[clrLoading]="createDatabaseLoading"
|
||||
(click)="createDatabase()"
|
||||
class="btn btn-primary mt-10"
|
||||
>
|
||||
Create Database
|
||||
</button>
|
||||
@if (makeDataResponse.length > 0) {
|
||||
<p class="mb-0 mt-10">Create Database Completed</p>
|
||||
<hr />
|
||||
@if (makeDataResponse.length > 0) {
|
||||
<div class="log-wrapper">
|
||||
{{ makeDataResponse }}
|
||||
</div>
|
||||
}
|
||||
<button (click)="navigateToHome()" class="btn btn-primary mt-15">
|
||||
Let's get started
|
||||
</button>
|
||||
<button
|
||||
(click)="downloadFile(makeDataResponse, 'create-database-log', 'txt')"
|
||||
class="btn btn-primary mt-15"
|
||||
>
|
||||
Download log
|
||||
</button>
|
||||
<button (click)="validateDeploy()" class="btn btn-primary mt-15">
|
||||
Validate
|
||||
</button>
|
||||
@if (validationState !== 'none' || isValidating) {
|
||||
<div class="validation-bar">
|
||||
@if (isValidating) {
|
||||
<span class="spinner spinner-inline mr-10">
|
||||
Validating deploy...
|
||||
</span>
|
||||
<span> Validating deploy... </span>
|
||||
}
|
||||
@if (!isValidating && validationState === 'error') {
|
||||
<clr-icon shape="exclamation-circle" class="is-error"></clr-icon>
|
||||
<span> Validation failed </span>
|
||||
}
|
||||
@if (!isValidating && validationState === 'success') {
|
||||
<clr-icon shape="check-circle" class="is-success"></clr-icon>
|
||||
<span> Validation succeeded </span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
Whitespace-only changes.
@@ -1,90 +0,0 @@
|
||||
import { ManualComponent } from './manual.component'
|
||||
|
||||
// validateDeploy() is fire-and-forget (doesn't return its own promise
|
||||
// chain), so tests need to wait for it to settle some other way. A
|
||||
// macrotask boundary guarantees every already-queued microtask (regardless
|
||||
// of how many .then() hops validateDeploy's chain has) has run first.
|
||||
const flushPromiseChain = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
/**
|
||||
* validateDeploy() only touches sasService.request, eventService.showInfoModal
|
||||
* and loggerService.log - these stubs cover exactly that surface, not the
|
||||
* full real services (no TestBed/DI needed, same plain-instantiation-with-
|
||||
* stubs precedent as app.service.spec.ts).
|
||||
*/
|
||||
const buildDeps = () => {
|
||||
const sasService: any = {
|
||||
request: jasmine.createSpy('request')
|
||||
}
|
||||
const eventService: any = {
|
||||
showInfoModal: jasmine.createSpy('showInfoModal')
|
||||
}
|
||||
const loggerService: any = {
|
||||
log: jasmine.createSpy('log')
|
||||
}
|
||||
const deployService: any = {}
|
||||
|
||||
return { sasService, eventService, loggerService, deployService }
|
||||
}
|
||||
|
||||
const buildManualComponent = (deps: ReturnType<typeof buildDeps>) =>
|
||||
new ManualComponent(
|
||||
deps.sasService,
|
||||
deps.eventService,
|
||||
deps.loggerService,
|
||||
deps.deployService
|
||||
)
|
||||
|
||||
describe('ManualComponent - validateDeploy', () => {
|
||||
it('sets validationState to success when saslibs is present on a normal object response', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.resolveTo({
|
||||
adapterResponse: { saslibs: { SOME_LIB: ['SOME_TABLE'] } }
|
||||
})
|
||||
|
||||
const component = buildManualComponent(deps)
|
||||
component.validateDeploy()
|
||||
await flushPromiseChain()
|
||||
|
||||
expect(component.validationState).toBe('success')
|
||||
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets validationState to error with no modal when saslibs is missing from an otherwise normal object response', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.resolveTo({
|
||||
adapterResponse: { SYSSITE: 'SITE1' }
|
||||
// saslibs deliberately omitted
|
||||
})
|
||||
|
||||
const component = buildManualComponent(deps)
|
||||
component.validateDeploy()
|
||||
await flushPromiseChain()
|
||||
|
||||
expect(component.validationState).toBe('error')
|
||||
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Same underlying bug as app.service.spec.ts's equivalent test: a
|
||||
// misconfigured Viya computeTasks deployment returns a raw "Job error"
|
||||
// text body, which the adapter resolves as adapterResponse verbatim.
|
||||
it('sets validationState to error and shows the real response text when adapterResponse is a raw string, not an object', async () => {
|
||||
const deps = buildDeps()
|
||||
const rawJobError = [
|
||||
'Job error',
|
||||
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context 340cd3eb-72ae is not reusable. Please provide a reusable context or supply the ID of an existing session in the task request.',
|
||||
'path: /compute/tasks',
|
||||
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
|
||||
].join('\n')
|
||||
deps.sasService.request.and.resolveTo({ adapterResponse: rawJobError })
|
||||
|
||||
const component = buildManualComponent(deps)
|
||||
component.validateDeploy()
|
||||
await flushPromiseChain()
|
||||
|
||||
expect(component.validationState).toBe('error')
|
||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
|
||||
expect(message).toContain(rawJobError)
|
||||
})
|
||||
})
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core'
|
||||
import SASjs, { SASjsConfig } from '@sasjs/adapter'
|
||||
import { DcAdapterSettings } from 'src/app/models/DcAdapterSettings'
|
||||
import { RequestWrapperResponse } from 'src/app/models/request-wrapper/RequestWrapperResponse'
|
||||
import { DeployService } from 'src/app/services/deploy.service'
|
||||
import { EventService } from 'src/app/services/event.service'
|
||||
import { LoggerService } from 'src/app/services/logger.service'
|
||||
import { SasService } from 'src/app/services/sas.service'
|
||||
import { getMalformedAdapterResponseMessage } from 'src/app/shared/utils/get-malformed-adapter-response-message'
|
||||
|
||||
@Component({
|
||||
selector: 'app-manual-deploy',
|
||||
templateUrl: './manual.component.html',
|
||||
styleUrls: ['./manual.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class ManualComponent implements OnInit {
|
||||
@Input() sasJs!: SASjs
|
||||
@Input() sasJsConfig: SASjsConfig = new SASjsConfig()
|
||||
@Input() dcAdapterSettings: DcAdapterSettings | undefined
|
||||
|
||||
@Output() onNavigateToHome: EventEmitter<any> = new EventEmitter<any>()
|
||||
|
||||
public needsLogin: boolean = false
|
||||
public adminGroups: any = []
|
||||
public allContexts: any = []
|
||||
|
||||
public appLoc: string = ''
|
||||
public dcPath: string = ''
|
||||
public selectedAdminGroup: string = ''
|
||||
public selectedContext: string = ''
|
||||
public jobLog: string = ''
|
||||
public makeDataResponse: string = ''
|
||||
public linesOfCode: any = []
|
||||
public fileName: string = ''
|
||||
public preloadedFile: boolean = true
|
||||
public executeSASEnabled: boolean = false
|
||||
|
||||
public contextsLoading: boolean = false
|
||||
public createDatabaseLoading: boolean = false
|
||||
public executingScript: boolean = false
|
||||
public downloadFileBtn: boolean = false
|
||||
public isValidating: boolean = false
|
||||
public jsonFile: any = null
|
||||
public isSubmittingJson: boolean = false
|
||||
public isJsonSubmitted: boolean = false
|
||||
public validationState: string = 'none'
|
||||
|
||||
constructor(
|
||||
private sasService: SasService,
|
||||
private eventService: EventService,
|
||||
private loggerService: LoggerService,
|
||||
private deployService: DeployService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
/**
|
||||
* FIXME: Remove
|
||||
*/
|
||||
public async executableContext() {
|
||||
// getExecutableContexts now need AuthConfig parameter which we don't have on web
|
||||
// this.contextsLoading = true
|
||||
// let contexts = await this.sasJs.getExecutableContexts()
|
||||
// this.allContexts = contexts
|
||||
// if (contexts.length === 0) {
|
||||
// this.selectedContext = ''
|
||||
// } else {
|
||||
// this.selectedContext = this.allContexts[0].name
|
||||
// }
|
||||
// this.contextsLoading = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes sas.json file attached to the input
|
||||
*/
|
||||
public clearUploadInput(event: Event) {
|
||||
this.deployService.clearUploadInput(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads attached SAS file to be sent to sas for execution (backend deploy)
|
||||
*/
|
||||
public onSasFileChange(event: any) {
|
||||
this.preloadedFile = false
|
||||
|
||||
let file = event.target.files[0]
|
||||
this.fileName = file.name
|
||||
|
||||
let fileReader = new FileReader()
|
||||
|
||||
fileReader.onload = () => {
|
||||
if (fileReader.result) {
|
||||
this.linesOfCode = (fileReader.result as string).split('\n')
|
||||
this.linesOfCode = this.linesOfCode.filter((el: string) => {
|
||||
return el !== '' && el !== null
|
||||
})
|
||||
|
||||
this.executeSASEnabled = true
|
||||
|
||||
this.addPrecodeLines()
|
||||
}
|
||||
}
|
||||
|
||||
fileReader.readAsText(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads attached JSON file to be sent to sas for execution (backend deploy)
|
||||
*/
|
||||
public async onJsonFileChange(event: any) {
|
||||
let file = event.target.files[0]
|
||||
|
||||
this.jsonFile = await this.deployService.readFile(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Appending precode lines to the attached sas or json file for backend deploy
|
||||
*/
|
||||
public addPrecodeLines() {
|
||||
let headerLines = [
|
||||
`%let context=${this.selectedContext};`,
|
||||
`%let appLoc=${this.appLoc};`,
|
||||
`%let admin=${this.selectedAdminGroup};`,
|
||||
`%let dcpath=${this.dcPath};`
|
||||
]
|
||||
|
||||
this.linesOfCode.unshift(...headerLines)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloadng file with precode included
|
||||
*/
|
||||
public downloadSasPrecodeFile() {
|
||||
let linesAsText = this.linesOfCode.join('\n')
|
||||
let filename = this.fileName.split('.')[0]
|
||||
|
||||
this.downloadFile(linesAsText, filename, 'sas')
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for downloading log and repsonse as a file
|
||||
*/
|
||||
public downloadFile(
|
||||
content: any,
|
||||
filename: string,
|
||||
extension: string = 'txt'
|
||||
) {
|
||||
this.deployService.downloadFile(content, filename, extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving dcpath to localstorage
|
||||
* FIXME: maybe it'snot necessary
|
||||
*/
|
||||
public saveDcPath() {
|
||||
localStorage.setItem('deploy_dc_loc', this.dcPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send sas.json to be executed (deploying backend)
|
||||
*/
|
||||
public async executeJson() {
|
||||
this.isSubmittingJson = true
|
||||
|
||||
try {
|
||||
let uploadJsonFile = await this.sasJs.deployServicePack(
|
||||
this.jsonFile,
|
||||
this.dcAdapterSettings?.appLoc || '',
|
||||
undefined,
|
||||
undefined,
|
||||
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.eventService.showInfoModal(
|
||||
'Deploy error',
|
||||
`Exception: \n ${textEx !== '' ? textEx : ex}`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.isSubmittingJson = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Send sas file to be executed (deploying backend)
|
||||
*/
|
||||
public async executeSAS() {
|
||||
this.executingScript = true
|
||||
this.jobLog = ''
|
||||
this.makeDataResponse = ''
|
||||
|
||||
try {
|
||||
let executeResponse = await this.sasJs.executeScript({
|
||||
fileName: this.fileName,
|
||||
linesOfCode: this.linesOfCode,
|
||||
contextName: this.selectedContext
|
||||
})
|
||||
|
||||
this.loggerService.log(executeResponse)
|
||||
|
||||
if (typeof executeResponse.log === 'string') {
|
||||
executeResponse.log = JSON.parse(executeResponse.log)
|
||||
}
|
||||
|
||||
if (executeResponse.jobStatus === 'error') {
|
||||
alert('Error!')
|
||||
} else {
|
||||
this.jobLog = executeResponse.log.items
|
||||
? executeResponse.log.items.map((i: any) => i.line).join('\n')
|
||||
: JSON.stringify(executeResponse.log)
|
||||
}
|
||||
|
||||
this.executingScript = false
|
||||
} catch (err) {
|
||||
this.executingScript = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Running makedata service
|
||||
* @param newTab open and run in new tab
|
||||
*/
|
||||
public createDatabase(newTab: boolean = true) {
|
||||
if (newTab) {
|
||||
// Debug is ALWAYS enabled for makedata — it runs only once during
|
||||
// deployment and the full log is needed to diagnose any issues.
|
||||
const _debug = '&_debug=131'
|
||||
let url =
|
||||
this.sasService.getSasjsConfig().serverUrl +
|
||||
'/SASJobExecution/?_program=' +
|
||||
this.dcAdapterSettings?.appLoc ||
|
||||
'' +
|
||||
'/admin/makedata' +
|
||||
'&ADMIN=' +
|
||||
this.selectedAdminGroup +
|
||||
'&DCPATH=' +
|
||||
this.dcPath +
|
||||
_debug
|
||||
|
||||
window.open(url, '_blank')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.createDatabaseLoading = true
|
||||
|
||||
let data = {
|
||||
fromjs: [
|
||||
{
|
||||
ADMIN: this.selectedAdminGroup,
|
||||
DCPATH: this.dcPath
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* We are overriding default `sasjsConfig` object fields with this object fields.
|
||||
* Here we want to run this request using original WEB method.
|
||||
* contextName: null is the MUST field for it.
|
||||
*/
|
||||
let overrideConfig = {
|
||||
useComputeApi: null,
|
||||
contextName: this.sasJsConfig.contextName,
|
||||
debug: true
|
||||
}
|
||||
|
||||
this.sasJs
|
||||
.request(`services/admin/makedata`, data, overrideConfig, () => {
|
||||
this.sasService.shouldLogin.next(true)
|
||||
})
|
||||
.then((res: any) => {
|
||||
try {
|
||||
this.makeDataResponse = JSON.stringify(res)
|
||||
} catch {
|
||||
this.makeDataResponse = res
|
||||
}
|
||||
|
||||
this.createDatabaseLoading = false
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.createDatabaseLoading = false
|
||||
|
||||
try {
|
||||
this.makeDataResponse = JSON.stringify(err)
|
||||
} catch {
|
||||
this.makeDataResponse = err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public navigateToHome() {
|
||||
this.onNavigateToHome.emit()
|
||||
}
|
||||
|
||||
public validateDeploy() {
|
||||
this.isValidating = true
|
||||
|
||||
this.sasService
|
||||
.request('public/startupservice', null)
|
||||
.then((res: RequestWrapperResponse) => {
|
||||
this.loggerService.log(res.adapterResponse)
|
||||
|
||||
const malformedMessage = getMalformedAdapterResponseMessage(
|
||||
res.adapterResponse
|
||||
)
|
||||
if (malformedMessage) {
|
||||
this.validationState = 'error'
|
||||
this.eventService.showInfoModal('Error', malformedMessage)
|
||||
} else if (res.adapterResponse.saslibs) {
|
||||
this.validationState = 'success'
|
||||
} else {
|
||||
this.validationState = 'error'
|
||||
}
|
||||
|
||||
this.isValidating = false
|
||||
})
|
||||
.catch((err: any) => {
|
||||
this.isValidating = false
|
||||
this.validationState = 'error'
|
||||
})
|
||||
}
|
||||
|
||||
public deleteKeys() {
|
||||
localStorage.removeItem('deploy_dc_loc')
|
||||
}
|
||||
}
|
||||
@@ -369,11 +369,30 @@
|
||||
</div>
|
||||
}
|
||||
@if (!['', ' '].includes(queryText)) {
|
||||
<div class="clr-col-md-12 infoBar">
|
||||
<span
|
||||
<div
|
||||
#infoBar
|
||||
class="clr-col-md-12 infoBar"
|
||||
[class.expanded]="filterExpanded"
|
||||
>
|
||||
<span class="infoBar-text"
|
||||
>FILTER :
|
||||
<b>{{ queryText }}</b>
|
||||
</span>
|
||||
@if (filterOverflows) {
|
||||
<button
|
||||
type="button"
|
||||
class="infoBar-toggle"
|
||||
[attr.aria-expanded]="filterExpanded"
|
||||
[attr.aria-label]="
|
||||
filterExpanded
|
||||
? 'Collapse the filter clause'
|
||||
: 'Expand the filter clause'
|
||||
"
|
||||
(click)="toggleFilterPanel()"
|
||||
>
|
||||
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AfterViewChecked,
|
||||
AfterViewInit,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
@@ -112,7 +113,9 @@ import { ParseResult } from '../models/ParseResult.interface'
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
export class EditorComponent
|
||||
implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy
|
||||
{
|
||||
@ViewChildren('uploadStater')
|
||||
uploadStaterCompList: QueryList<UploadStaterComponent> = new QueryList()
|
||||
@ViewChildren('queryFilter')
|
||||
@@ -525,6 +528,19 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
public tableData: Array<any> = []
|
||||
public queryText = ''
|
||||
public queryTextSaved = ''
|
||||
|
||||
/**
|
||||
* The applied-filter panel is collapsed to a single line by default. The
|
||||
* chevron that expands it is only useful when the clause does not fit that
|
||||
* line, which depends on the rendered width - so it is measured from the DOM
|
||||
* rather than guessed from the length of the text.
|
||||
*/
|
||||
public filterExpanded = false
|
||||
public filterOverflows = false
|
||||
|
||||
@ViewChild('infoBar') infoBar?: ElementRef<HTMLElement>
|
||||
|
||||
private filterMeasuredKey = ''
|
||||
public showApprovers = false
|
||||
public pkDups = false
|
||||
public validationDone = 0
|
||||
@@ -1190,7 +1206,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.cdf.detectChanges()
|
||||
|
||||
this.submitLoading = false
|
||||
this.sasStoreService.setQueryVariables(this.libds, this.cols)
|
||||
// The columns that came with the table are the cheapest source for the
|
||||
// picker. When the table was not returned - the row/cell limits abort the
|
||||
// request before the data arrives - that list is empty, so the store
|
||||
// falls back to public/getcols rather than leaving the picker with
|
||||
// nothing to pick from.
|
||||
this.sasStoreService
|
||||
.setQueryVariablesFromTable(this.libds, this.cols)
|
||||
.catch((err: any) =>
|
||||
this.eventService.catchResponseError('public/getcols', err)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3547,6 +3572,48 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewChecked() {
|
||||
this.scheduleFilterOverflowCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures whether the applied-filter clause fits the collapsed single line,
|
||||
* and shows or hides the chevron accordingly. The measurement is deferred
|
||||
* because `filterOverflows` and `filterExpanded` are bound in this view -
|
||||
* setting them inside the change-detection cycle would raise
|
||||
* ExpressionChangedAfterItHasBeenChecked.
|
||||
*/
|
||||
private scheduleFilterOverflowCheck() {
|
||||
const el = this.infoBar?.nativeElement
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = el.querySelector('.infoBar-text') as HTMLElement | null
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
// re-measure only when the clause, the available width, or the state changes
|
||||
const key = `${this.queryText}|${text.clientWidth}|${this.filterExpanded}`
|
||||
if (key === this.filterMeasuredKey) {
|
||||
return
|
||||
}
|
||||
this.filterMeasuredKey = key
|
||||
|
||||
setTimeout(() => {
|
||||
// while expanded the clause wraps, so there is nothing to measure
|
||||
if (this.filterExpanded) {
|
||||
return
|
||||
}
|
||||
this.filterOverflows = text.scrollWidth > text.clientWidth
|
||||
})
|
||||
}
|
||||
|
||||
public toggleFilterPanel() {
|
||||
this.filterExpanded = !this.filterExpanded
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
// Fix ARIA accessibility issues after table initialization
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -417,9 +417,14 @@ describe('formulas plugin must not be disabled/re-enabled around a table load',
|
||||
* capping maxRows at editor_rows_allowed alone makes HyperFormula reject
|
||||
* any table bigger than that cap outright. See initSetup's own
|
||||
* Math.max(dataSource.length, editor_rows_allowed) fix.
|
||||
*
|
||||
* Handsontable 18.1 fixed the engine coupling upstream (#10672): the grid's
|
||||
* maxRows/maxCols no longer limit the HyperFormula engine, so a capped
|
||||
* maxRows no longer rejects a larger existing dataset. DC's own guard is
|
||||
* still in place; the first test below now pins the fixed behaviour.
|
||||
*/
|
||||
describe('maxRows must never be smaller than the actual loaded row count', () => {
|
||||
it('throws when maxRows is capped below the real row count (proves the mechanism)', () => {
|
||||
it('no longer throws when maxRows is capped below the real row count (HOT 18.1 #10672)', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const colNames = ['PK', 'A', 'B']
|
||||
@@ -440,7 +445,7 @@ describe('maxRows must never be smaller than the actual loaded row count', () =>
|
||||
error = e
|
||||
}
|
||||
|
||||
expect(error?.message).toEqual('Sheet size limit exceeded')
|
||||
expect(error).toBeNull()
|
||||
})
|
||||
|
||||
it('does not throw when maxRows is never let below the actual row count (the fix)', () => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import { makeNumberFormatRenderer } from './renderers.utils'
|
||||
import {
|
||||
makeNumberFormatRenderer,
|
||||
errorRenderer,
|
||||
noSpinnerRenderer,
|
||||
spinnerRenderer
|
||||
} from './renderers.utils'
|
||||
|
||||
describe('makeNumberFormatRenderer', () => {
|
||||
it('renders a numeric cell as EUR currency without changing the value', () => {
|
||||
@@ -86,3 +91,67 @@ describe('makeNumberFormatRenderer', () => {
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* DOM-injection reproduction mirroring the editor's cell-render cycle.
|
||||
* During dynamic cell validation the editor applies one of the status
|
||||
* renderers to a cell via setCellMeta + hot.render(). Those renderers paint
|
||||
* the cell value with td.innerHTML, so a value containing markup is injected
|
||||
* and executed (the <img onerror> fires in the browser). The value can come
|
||||
* straight from a dataset row served by the getdata stored program, or from a
|
||||
* typed edit. These fail on the vulnerable implementation and pass once the
|
||||
* renderer escapes the value.
|
||||
*/
|
||||
describe('grid cell renderers do not inject raw HTML', () => {
|
||||
const maliciousValue = '<img src=x onerror=alert(1)>'
|
||||
|
||||
// Seed a real Handsontable grid with the payload as a loaded cell value,
|
||||
// then apply the given status renderer and render — exactly the sequence the
|
||||
// editor uses during the dynamic-validation cycle.
|
||||
const renderWith = (
|
||||
renderer: (
|
||||
i: any,
|
||||
td: any,
|
||||
r: number,
|
||||
c: number,
|
||||
p: any,
|
||||
v: any,
|
||||
cp: any
|
||||
) => any
|
||||
) => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const hot = new Handsontable(container, {
|
||||
data: [{ SOME_CHAR: maliciousValue }],
|
||||
columns: [{ data: 'SOME_CHAR', type: 'text' }],
|
||||
licenseKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
hot.render()
|
||||
hot.setCellMeta(0, 0, 'renderer', renderer)
|
||||
hot.render()
|
||||
const td: HTMLTableCellElement | null = hot.getCell(0, 0)
|
||||
hot.destroy()
|
||||
container.remove()
|
||||
return td
|
||||
}
|
||||
|
||||
// A vulnerable renderer turns the value into a real <img> element with an
|
||||
// onerror handler (proven by the browser firing alert(1)). A safe
|
||||
// renderer leaves no such element. Asserting on the parsed DOM rather
|
||||
// than the raw string avoids false passes from browser attribute normalising.
|
||||
const assertNoInjectedElement = (td: HTMLTableCellElement | null) => {
|
||||
expect(td?.querySelector('img[onerror]')).toBeNull()
|
||||
}
|
||||
|
||||
it('noSpinnerRenderer escapes rather than injecting the value', () => {
|
||||
assertNoInjectedElement(renderWith(noSpinnerRenderer))
|
||||
})
|
||||
|
||||
it('errorRenderer escapes rather than injecting the value', () => {
|
||||
assertNoInjectedElement(renderWith(errorRenderer))
|
||||
})
|
||||
|
||||
it('spinnerRenderer escapes rather than injecting the value', () => {
|
||||
assertNoInjectedElement(renderWith(spinnerRenderer))
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,23 @@
|
||||
import Handsontable from 'handsontable'
|
||||
|
||||
/**
|
||||
* Returns string-safe text of any value so it can be assigned to innerHTML.
|
||||
* The cell values painted by the status renderers are user/DB-controlled,
|
||||
* so they must never be parsed as HTML by the browser — escaping turns any
|
||||
* embedded markup into inert text.
|
||||
*/
|
||||
const escapeHtml = (value: any): string =>
|
||||
String(value ?? '').replace(/[&<>"']/g, (char) => {
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return entities[char]
|
||||
})
|
||||
|
||||
/**
|
||||
* Builds a display-only HOT renderer that formats numeric cell values using
|
||||
* Intl.NumberFormat. The stored/submitted value is never changed — only the
|
||||
@@ -67,9 +85,7 @@ export const errorRenderer = (
|
||||
) => {
|
||||
addDarkClass(td)
|
||||
|
||||
td.innerHTML = `${
|
||||
value ? value.toString() : ''
|
||||
} <cds-icon shape="exclamation-triangle" status="warning"></cds-icon>`
|
||||
td.innerHTML = `${escapeHtml(value)} <cds-icon shape="exclamation-triangle" status="warning"></cds-icon>`
|
||||
|
||||
return td
|
||||
}
|
||||
@@ -89,7 +105,7 @@ export const noSpinnerRenderer = (
|
||||
) => {
|
||||
addDarkClass(td)
|
||||
|
||||
td.innerHTML = value ? value : ''
|
||||
td.innerHTML = escapeHtml(value)
|
||||
|
||||
return td
|
||||
}
|
||||
@@ -110,9 +126,7 @@ export const spinnerRenderer = (
|
||||
) => {
|
||||
addDarkClass(td)
|
||||
|
||||
td.innerHTML = `${
|
||||
value ? value.toString() : ''
|
||||
} <span class="spinner spinner-sm vertical-align-middle"></span>`
|
||||
td.innerHTML = `${escapeHtml(value)} <span class="spinner spinner-sm vertical-align-middle"></span>`
|
||||
|
||||
return td
|
||||
}
|
||||
|
||||
@@ -287,9 +287,13 @@ describe('syncOverwrittenCommentForCell resolves the correct visual cell on a so
|
||||
* trigger it, independent of what it actually changes. editTable(),
|
||||
* cancelEdit(), and every other place editor.component.ts calls
|
||||
* updateSettings() after the grid could already be sorted (e.g. the user
|
||||
* sorted while still read-only, then clicked Edit) hits this. The fix
|
||||
* (updateSettingsSortSafe) clears the sort before the call and restores it
|
||||
* immediately afterward.
|
||||
* sorted while still read-only, then clicked Edit) hits this.
|
||||
*
|
||||
* Fixed upstream in Handsontable 18.1 (the formulas plugin now keeps its
|
||||
* index translations across updateSettings), so the corruption no longer
|
||||
* occurs. DC's own guard (updateSettingsSortSafe - clear the sort, call
|
||||
* updateSettings, restore the sort) is still in place and still correct;
|
||||
* this test now pins the fixed behaviour so a future regression shows up.
|
||||
*/
|
||||
describe('updateSettings must not run while a sort is active - it corrupts formula cell references', () => {
|
||||
const buildSortedGrid = (): Handsontable => {
|
||||
@@ -340,13 +344,13 @@ describe('updateSettings must not run while a sort is active - it corrupts formu
|
||||
hot.getDataAtRowProp(r, 'FORMULA_HARD_COL')
|
||||
)
|
||||
|
||||
it('demonstrates the failure mode: updateSettings while sorted corrupts formula cells', () => {
|
||||
it('no longer corrupts formula cells when updateSettings runs while sorted (HOT 18.1)', () => {
|
||||
const hot = buildSortedGrid()
|
||||
|
||||
hot.updateSettings({}, false)
|
||||
hot.render()
|
||||
|
||||
expect(formulaHardColValues(hot)).toContain('#REF!')
|
||||
expect(formulaHardColValues(hot)).not.toContain('#REF!')
|
||||
|
||||
hot.destroy()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { SASjsConfig } from '@sasjs/adapter'
|
||||
|
||||
export interface DcAdapterSettings extends Partial<SASjsConfig> {
|
||||
adminGroup: string
|
||||
dcPath: string
|
||||
hotLicenceKey: string
|
||||
}
|
||||
@@ -8,13 +8,20 @@ import * as encoding from 'text-encoding'
|
||||
import { Router } from '@angular/router'
|
||||
import { Globvar } from '../models/sas/public-startupservice.model'
|
||||
import { freeTierConfig } from '../free-tier.config'
|
||||
import { AppStoreService } from './app-store.service'
|
||||
import { HelperService } from './helper.service'
|
||||
import { LicenceState } from '../models/LicenceState'
|
||||
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
|
||||
import { LoggerService } from './logger.service'
|
||||
import { EventService } from './event.service'
|
||||
|
||||
/**
|
||||
* Handsontable licence key used until a licence is loaded from the backend.
|
||||
* The decrypted licence carries the customer's own key (`hot_license_key`), so
|
||||
* this is only the pre-licence / free-tier default - it used to be supplied by
|
||||
* the `<sasjs>` tag.
|
||||
*/
|
||||
export const DEFAULT_HOT_LICENCE_KEY = 'non-commercial-and-evaluation'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -43,7 +50,7 @@ export class LicenceService {
|
||||
|
||||
private freeTierLicenceData: LicenseKeyData = {
|
||||
demo: true,
|
||||
hot_license_key: this.appStoreService.getDcAdapterSettings()?.hotLicenceKey,
|
||||
hot_license_key: DEFAULT_HOT_LICENCE_KEY,
|
||||
users_allowed: this._licenceState.users_allowed || 1,
|
||||
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
|
||||
site_id: '',
|
||||
@@ -63,7 +70,6 @@ export class LicenceService {
|
||||
|
||||
constructor(
|
||||
private loggerService: LoggerService,
|
||||
private appStoreService: AppStoreService,
|
||||
private eventService: EventService,
|
||||
private sasService: SasService,
|
||||
private helperService: HelperService,
|
||||
@@ -79,8 +85,7 @@ export class LicenceService {
|
||||
}
|
||||
|
||||
public async activation(startupservice: any) {
|
||||
this.freeTierLicenceData.hot_license_key =
|
||||
this.appStoreService.getDcAdapterSettings()?.hotLicenceKey
|
||||
this.freeTierLicenceData.hot_license_key = DEFAULT_HOT_LICENCE_KEY
|
||||
|
||||
await this.setStartupserviceRules(startupservice)
|
||||
|
||||
@@ -225,8 +230,7 @@ export class LicenceService {
|
||||
? licenseData.hot_license_key
|
||||
: this.hot_license_key.value
|
||||
|
||||
if (!hotLicenceKey)
|
||||
hotLicenceKey = this.appStoreService.getDcAdapterSettings()?.hotLicenceKey
|
||||
if (!hotLicenceKey) hotLicenceKey = DEFAULT_HOT_LICENCE_KEY
|
||||
|
||||
this.hot_license_key.next(hotLicenceKey)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { SasStoreService } from './sas-store.service'
|
||||
|
||||
/**
|
||||
* The filter picker keys its operator set, its variable highlight and its value
|
||||
* quoting on Col.TYPE being exactly 'num' or 'char'. On the normal path
|
||||
* mergeColsRules derives that from $dataFormats; on the public/getcols fallback
|
||||
* (used when the EDIT/VIEW limits abort the load) the service speaks the SAS
|
||||
* vocabulary (TYPE C/N, DDTYPE separating DATE/DATETIME/TIME), so the store has
|
||||
* to map it. These tests pin both the mapping and the empty-payload guard.
|
||||
*/
|
||||
describe('SasStoreService', () => {
|
||||
const buildStore = (cols: any[]) =>
|
||||
new SasStoreService(
|
||||
{
|
||||
request: () => Promise.resolve({ adapterResponse: { cols } })
|
||||
} as any,
|
||||
{} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
const collectColumns = (store: SasStoreService) => {
|
||||
const seen: any[] = []
|
||||
store.columns.subscribe((value: any) => seen.push(value))
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('getCols', () => {
|
||||
it('maps the SAS C/N TYPE onto the picker vocabulary', async () => {
|
||||
const store = buildStore([
|
||||
{ NAME: 'CHAR_COL', TYPE: 'C', DDTYPE: 'C' },
|
||||
{ NAME: 'NUM_COL', TYPE: 'N', DDTYPE: 'N' },
|
||||
{ NAME: 'DATE_COL', TYPE: 'N', DDTYPE: 'DATE' },
|
||||
{ NAME: 'DATETIME_COL', TYPE: 'N', DDTYPE: 'DATETIME' },
|
||||
{ NAME: 'TIME_COL', TYPE: 'N', DDTYPE: 'TIME' }
|
||||
])
|
||||
|
||||
const cols = await store.getCols('DC.MY_TABLE')
|
||||
|
||||
expect(cols.map((col: any) => col.TYPE)).toEqual([
|
||||
'char',
|
||||
'num',
|
||||
'num',
|
||||
'num',
|
||||
'num'
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the rest of the column payload untouched', async () => {
|
||||
const store = buildStore([
|
||||
{
|
||||
NAME: 'CHAR_COL',
|
||||
TYPE: 'C',
|
||||
DDTYPE: 'C',
|
||||
LENGTH: 8,
|
||||
LABEL: 'A label'
|
||||
}
|
||||
])
|
||||
|
||||
const cols = await store.getCols('DC.MY_TABLE')
|
||||
|
||||
expect(cols[0].NAME).toBe('CHAR_COL')
|
||||
expect(cols[0].DDTYPE).toBe('C')
|
||||
expect(cols[0].LENGTH).toBe(8)
|
||||
expect(cols[0].LABEL).toBe('A label')
|
||||
})
|
||||
|
||||
it('returns an empty list rather than throwing when the payload is empty', async () => {
|
||||
const store = buildStore([])
|
||||
|
||||
const cols = await store.getCols('DC.MY_TABLE')
|
||||
|
||||
expect(cols).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('setQueryVariablesFromTable', () => {
|
||||
it('publishes the table columns without calling the service', async () => {
|
||||
let called = false
|
||||
const store = new SasStoreService(
|
||||
{
|
||||
request: () => {
|
||||
called = true
|
||||
return Promise.resolve({})
|
||||
}
|
||||
} as any,
|
||||
{} as any,
|
||||
{} as any
|
||||
)
|
||||
const seen = collectColumns(store)
|
||||
|
||||
await store.setQueryVariablesFromTable('DC.MY_TABLE', [{ NAME: 'A' }])
|
||||
|
||||
expect(called).toBe(false)
|
||||
expect(seen.length).toBe(1)
|
||||
expect(seen[0].data.cols.length).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to getcols and publishes the mapped columns', async () => {
|
||||
const store = buildStore([{ NAME: 'A', TYPE: 'C', DDTYPE: 'C' }])
|
||||
const seen = collectColumns(store)
|
||||
|
||||
await store.setQueryVariablesFromTable('DC.MY_TABLE', [])
|
||||
|
||||
expect(seen.length).toBe(1)
|
||||
expect(seen[0].libds).toBe('DC.MY_TABLE')
|
||||
expect(seen[0].data.cols[0].TYPE).toBe('char')
|
||||
})
|
||||
|
||||
it('does not publish an empty fallback, which the picker cannot render', async () => {
|
||||
const store = buildStore([])
|
||||
const seen = collectColumns(store)
|
||||
|
||||
await store.setQueryVariablesFromTable('DC.MY_TABLE', [])
|
||||
|
||||
expect(seen.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -295,6 +295,68 @@ export class SasStoreService {
|
||||
this.columns.next(columnsData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Column metadata for the filter picker, read from the service.
|
||||
*
|
||||
* The picker's variable list normally comes from the columns that arrived
|
||||
* with the table (see setQueryVariablesFromTable). This is the fallback for
|
||||
* when there is no table - public/getcols reads the metadata only.
|
||||
*
|
||||
* public/getcols speaks the SAS vocabulary: TYPE is C/N and DDTYPE separates
|
||||
* DATE, DATETIME and TIME. The picker keys its operator set (query.component),
|
||||
* its variable highlight and its value quoting (sas-store) on the client
|
||||
* vocabulary instead - TYPE is exactly 'num' or 'char' - which
|
||||
* mergeColsRules derives from $dataFormats on the normal path. That pass
|
||||
* never runs for this fallback, so map TYPE here, with the same rule: the
|
||||
* date and time types are numeric.
|
||||
*/
|
||||
public async getCols(libds: string) {
|
||||
const tables = { iwant: [{ libds: libds }] }
|
||||
|
||||
const res = await this.sasService.request('public/getcols', tables)
|
||||
|
||||
const cols = res.adapterResponse.cols || []
|
||||
|
||||
return cols.map((col: any) => ({
|
||||
...col,
|
||||
TYPE:
|
||||
['DATE', 'DATETIME', 'TIME'].includes(col.DDTYPE) || col.TYPE === 'N'
|
||||
? 'num'
|
||||
: 'char'
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds the filter picker its variable list.
|
||||
*
|
||||
* `cols` is the column metadata that came back with the table, which costs
|
||||
* nothing extra and is the normal path. When the table was not returned - the
|
||||
* EDIT/VIEW row and cell limits abort the request before the data arrives -
|
||||
* that list is empty, which is what makes "please filter and try again"
|
||||
* unactionable: the picker has nothing to offer. Fall back to
|
||||
* public/getcols, which reads the column metadata without the rows.
|
||||
*/
|
||||
public async setQueryVariablesFromTable(libds: string, cols: any[]) {
|
||||
if (cols && cols.length > 0) {
|
||||
this.setQueryVariables(libds, cols)
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = await this.getCols(libds)
|
||||
|
||||
/*
|
||||
public/getcols emits an empty cols table when the dataset has no
|
||||
metadata. Publishing that would push an empty array into the filter
|
||||
picker, whose subscriber reads cols[0].NAME - the throw is caught, but
|
||||
the picker is left half-updated (this.cols assigned, the filter.cols
|
||||
assignment below the throwing line never run). There is nothing to
|
||||
offer, so leave the picker as it was.
|
||||
*/
|
||||
if (!fallback || fallback.length === 0) return
|
||||
|
||||
this.setQueryVariables(libds, fallback)
|
||||
}
|
||||
|
||||
public async getChangeInfo(tableId: any) {
|
||||
let obj = { TABLE: tableId }
|
||||
let table = { SASControlTable: [obj] }
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { DEBUG_STATE_KEY, SasService } from './sas.service'
|
||||
|
||||
/**
|
||||
* SasService's constructor is a plain 8-argument constructor with an empty
|
||||
* body, and `request()` touches only a handful of collaborators, so this
|
||||
* stubs exactly that surface - no TestBed/DI needed, same precedent as
|
||||
* app.service.spec.ts and va-filter.service.spec.ts.
|
||||
*
|
||||
* The SASjs adapter is not built by the constructor either (sasServiceInit
|
||||
* does that), so it is assigned directly - and its `request` spy is where the
|
||||
* outgoing body is observable, which is the whole point of these tests.
|
||||
*/
|
||||
const buildService = () => {
|
||||
const sasjsAdapter: any = {
|
||||
request: jasmine.createSpy('request').and.resolveTo({}),
|
||||
getSasRequests: () => [],
|
||||
setDebugState: jasmine.createSpy('setDebugState')
|
||||
}
|
||||
const loggerService: any = {
|
||||
logRequestData: jasmine.createSpy('logRequestData')
|
||||
}
|
||||
const userService: any = { user: undefined }
|
||||
const eventService: any = {
|
||||
showAbortModal: jasmine.createSpy('showAbortModal'),
|
||||
startupDataLoaded: jasmine.createSpy('startupDataLoaded')
|
||||
}
|
||||
const router: any = { navigateByUrl: jasmine.createSpy('navigateByUrl') }
|
||||
|
||||
const service = new SasService(
|
||||
{} as any, // appStoreService
|
||||
userService,
|
||||
eventService,
|
||||
{} as any, // sasjsService
|
||||
{} as any, // sasViyaService
|
||||
loggerService,
|
||||
{} as any, // startupCheckService
|
||||
router
|
||||
)
|
||||
;(service as any).sasjsAdapter = sasjsAdapter
|
||||
|
||||
return { service, sasjsAdapter }
|
||||
}
|
||||
|
||||
/** The body the adapter was asked to send for the most recent request. */
|
||||
const sentBody = (sasjsAdapter: any) =>
|
||||
sasjsAdapter.request.calls.mostRecent().args[1]
|
||||
|
||||
describe('SasService diagnostics payload', () => {
|
||||
it('gives the startup service a body, so browser_info can ride on it', async () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
|
||||
// Both startup service call sites pass null - there is nothing to send -
|
||||
// and the service is the one whose log a support ticket is read from.
|
||||
await service.request('public/startupservice', null)
|
||||
|
||||
const body = sentBody(sasjsAdapter)
|
||||
expect(body).toBeTruthy()
|
||||
expect(body.browser_info.length).toBe(1)
|
||||
expect(body.browser_info[0].user_agent).toBe(navigator.userAgent)
|
||||
expect('timezone' in body.browser_info[0]).toBe(true)
|
||||
expect('tz_offset' in body.browser_info[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a non-diagnostics service payload alone', async () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
|
||||
await service.request('public/viewlibs', null)
|
||||
|
||||
expect(sentBody(sasjsAdapter)).toBeNull()
|
||||
})
|
||||
|
||||
it('adds browser_info to a body that already carries content', async () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
|
||||
await service.request('editors/getdata', { table: 'MPE_X_TEST' })
|
||||
|
||||
const body = sentBody(sasjsAdapter)
|
||||
expect(body.table).toBe('MPE_X_TEST')
|
||||
expect(body.browser_info.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not overwrite a browser_info the caller supplied', async () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
const supplied = [{ url: 'supplied by the caller' }]
|
||||
|
||||
await service.request('editors/getdata', { browser_info: supplied })
|
||||
|
||||
expect(sentBody(sasjsAdapter).browser_info).toBe(supplied)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The `<sasjs>` tag is re-read on every load, so the debug toggle has to survive
|
||||
* a refresh on its own.
|
||||
*/
|
||||
describe('SasService debug state', () => {
|
||||
/** Builds a service whose init can actually run (real adapter, stub deps). */
|
||||
const initService = (tagDebug = false) => {
|
||||
const appStoreService: any = {
|
||||
getDcAdapterSettings: () => ({
|
||||
serverUrl: '',
|
||||
appLoc: '/Public/app/dc',
|
||||
serverType: 'SAS9',
|
||||
loginMechanism: '',
|
||||
dcPath: '',
|
||||
debug: tagDebug,
|
||||
useComputeApi: null,
|
||||
runAsTask: true,
|
||||
contextName: ''
|
||||
})
|
||||
}
|
||||
|
||||
const service = new SasService(
|
||||
appStoreService,
|
||||
{ user: undefined } as any,
|
||||
{ showInfoModal: jasmine.createSpy('showInfoModal') } as any,
|
||||
{ setup: jasmine.createSpy('setup') } as any,
|
||||
{ setup: jasmine.createSpy('setup') } as any,
|
||||
{ logRequestData: jasmine.createSpy('logRequestData') } as any,
|
||||
{ updateStep: jasmine.createSpy('updateStep') } as any,
|
||||
{ navigateByUrl: jasmine.createSpy('navigateByUrl') } as any
|
||||
)
|
||||
|
||||
service.sasServiceInit()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.removeItem(DEBUG_STATE_KEY))
|
||||
afterEach(() => localStorage.removeItem(DEBUG_STATE_KEY))
|
||||
|
||||
it('stores the state when the toggle changes', () => {
|
||||
const { service, sasjsAdapter } = buildService()
|
||||
|
||||
service.setDebugState(true)
|
||||
|
||||
expect(localStorage.getItem(DEBUG_STATE_KEY)).toBe('true')
|
||||
expect(sasjsAdapter.setDebugState).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('stores a switch back off, rather than clearing it', () => {
|
||||
const { service } = buildService()
|
||||
|
||||
service.setDebugState(true)
|
||||
service.setDebugState(false)
|
||||
|
||||
expect(localStorage.getItem(DEBUG_STATE_KEY)).toBe('false')
|
||||
})
|
||||
|
||||
it('reinstates the stored state on the next load', () => {
|
||||
localStorage.setItem(DEBUG_STATE_KEY, 'true')
|
||||
|
||||
expect(initService().getSasjsConfig().debug).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves the tag value alone when nothing is stored', () => {
|
||||
expect(initService(true).getSasjsConfig().debug).toBe(true)
|
||||
})
|
||||
|
||||
it('reinstates an off state over a tag that turns debug on', () => {
|
||||
localStorage.setItem(DEBUG_STATE_KEY, 'false')
|
||||
|
||||
expect(initService(true).getSasjsConfig().debug).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,33 @@ import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapper
|
||||
import { SasViyaService } from './sas-viya.service'
|
||||
import { ViyaApiFolder } from '../viya-api-explorer/models/viya-api-folder.model'
|
||||
import { ViyaApiFolderMembers } from '../viya-api-explorer/models/viya-api-folder-content.model'
|
||||
import { parseUserAgent } from '../shared/utils/parse-user-agent'
|
||||
import { VERSION } from '../../environments/version'
|
||||
|
||||
/**
|
||||
* Services that receive the `browser_info` / `browser_url_vars` diagnostics
|
||||
* tables: the startup service (so every session logs its context once) and
|
||||
* every service that executes customer-provided code - hook scripts via
|
||||
* %mpe_runhook (getdata, stagedata, loadfile, restore, postdata) and the
|
||||
* dynamic cell dropdown programs (getdynamiccolvals). Other services never
|
||||
* see the tables, so the payload stays off the high-frequency calls.
|
||||
*/
|
||||
const DIAGNOSTICS_SERVICES = [
|
||||
'services/public/startupservice',
|
||||
'services/editors/getdata',
|
||||
'services/editors/getdynamiccolvals',
|
||||
'services/editors/stagedata',
|
||||
'services/editors/loadfile',
|
||||
'services/editors/restore',
|
||||
'services/auditors/postdata'
|
||||
]
|
||||
|
||||
/**
|
||||
* Where the debug toggle state is kept. The `<sasjs>` tag in index.html is
|
||||
* re-read on every load, so without this a refresh would drop the state the user
|
||||
* set from the UI.
|
||||
*/
|
||||
export const DEBUG_STATE_KEY = 'dc-debug-state'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -69,6 +96,13 @@ export class SasService {
|
||||
|
||||
this.sasjsAdapter = new SASjs(this.dcAdapterSettings)
|
||||
|
||||
/* The tag resets debug on every load, so reinstate the state the user last
|
||||
chose from the UI. */
|
||||
const storedDebugState = localStorage.getItem(DEBUG_STATE_KEY)
|
||||
if (storedDebugState !== null) {
|
||||
this.sasjsAdapter.setDebugState(storedDebugState === 'true')
|
||||
}
|
||||
|
||||
switch (this.dcAdapterSettings.serverType) {
|
||||
case ServerType.SasViya: {
|
||||
this.checkViyaDeploy(this.dcAdapterSettings.appLoc || '')
|
||||
@@ -118,6 +152,50 @@ export class SasService {
|
||||
|
||||
if (!wrapperOptions) wrapperOptions = {}
|
||||
|
||||
// Support diagnostics: tell the backend where the request came from.
|
||||
// Sent only to the services that can act on it - the startup service
|
||||
// (so every session logs its context once) and the services that
|
||||
// %include customer-provided code (hook scripts, dynamic cell dropdown
|
||||
// programs). This is the URL of this page - the Data Controller iframe -
|
||||
// not the document that embeds it, so an embedded report can be
|
||||
// distinguished. The browser_url_vars table carries the URL parameters
|
||||
// as name/value pairs, which is easier for a SAS developer to read than
|
||||
// parsing the url string.
|
||||
// The startup service is called with a null payload - there is nothing to
|
||||
// send - so give it one to carry the diagnostics. It is the service whose
|
||||
// log a support ticket is read from, and without this it never receives
|
||||
// them.
|
||||
if (
|
||||
DIAGNOSTICS_SERVICES.includes(url) &&
|
||||
(data === null || data === undefined)
|
||||
) {
|
||||
data = {}
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && !data.browser_info) {
|
||||
if (DIAGNOSTICS_SERVICES.includes(url)) {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const ua = parseUserAgent(navigator.userAgent)
|
||||
data.browser_info = [
|
||||
{
|
||||
url: window.location.href,
|
||||
referrer: document.referrer,
|
||||
timezone: tz || '',
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
locale: navigator.language || '',
|
||||
dc_version: VERSION.semverString,
|
||||
adapter_version: VERSION.adapterVersion,
|
||||
browser: ua.browser,
|
||||
browser_version: ua.browserVersion,
|
||||
platform: ua.platform,
|
||||
user_agent: navigator.userAgent || ''
|
||||
}
|
||||
]
|
||||
const urlVars = this.collectBrowserUrlVars()
|
||||
if (urlVars.length) data.browser_url_vars = urlVars
|
||||
}
|
||||
}
|
||||
|
||||
// If debug is on it will print what is going inside the adapter
|
||||
this.loggerService.logRequestData(url, data)
|
||||
|
||||
@@ -263,6 +341,34 @@ export class SasService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the page URL parameters as name/value pairs for the
|
||||
* `browser_url_vars` table. Parameters appear both in the search string
|
||||
* (before the hash) and in the hash query string (Angular routes carry
|
||||
* them after the `#`), so both are read - search first, and the first
|
||||
* occurrence of a name wins, so the search string value beats the hash
|
||||
* value on a collision.
|
||||
*/
|
||||
private collectBrowserUrlVars(): Array<{ name: string; value: string }> {
|
||||
const vars: { name: string; value: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const collect = (search: string) => {
|
||||
new URLSearchParams(search).forEach((value, name) => {
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name)
|
||||
vars.push({ name, value })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (window.location.search) collect(window.location.search.slice(1))
|
||||
const hashQuery = window.location.hash.split('?')[1]
|
||||
if (hashQuery) collect(hashQuery)
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to the backend, using the adapter upload function.
|
||||
*
|
||||
@@ -711,6 +817,7 @@ export class SasService {
|
||||
}
|
||||
|
||||
public setDebugState(state: boolean) {
|
||||
localStorage.setItem(DEBUG_STATE_KEY, String(state))
|
||||
this.sasjsAdapter.setDebugState(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
getSasjsTagDetails,
|
||||
SASJS_TAG_STEP,
|
||||
StartupCheckService
|
||||
} from './startup-check.service'
|
||||
|
||||
describe('StartupCheckService', () => {
|
||||
let service: StartupCheckService
|
||||
|
||||
beforeEach(() => {
|
||||
service = new StartupCheckService()
|
||||
})
|
||||
|
||||
it('includes a SASjs tag step after the startup checks', () => {
|
||||
const steps = service.steps$.value
|
||||
|
||||
expect(steps.length).toBe(SASJS_TAG_STEP + 1)
|
||||
expect(steps[SASJS_TAG_STEP].label).toBe('SASjs tag')
|
||||
expect(steps[SASJS_TAG_STEP].status).toBe('pending')
|
||||
})
|
||||
|
||||
it('shows the tag contents against the SASjs tag step', () => {
|
||||
service.setTagDetails([{ name: 'serverType', value: 'SASVIYA' }])
|
||||
|
||||
expect(service.steps$.value[SASJS_TAG_STEP]).toEqual({
|
||||
label: 'SASjs tag',
|
||||
status: 'done',
|
||||
details: [{ name: 'serverType', value: 'SASVIYA' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('flags a missing tag on the SASjs tag step', () => {
|
||||
service.updateStep(SASJS_TAG_STEP, 'error', 'SASJS TAG MISSING!!')
|
||||
|
||||
expect(service.steps$.value[SASJS_TAG_STEP]).toEqual({
|
||||
label: 'SASjs tag',
|
||||
status: 'error',
|
||||
detail: 'SASJS TAG MISSING!!'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSasjsTagDetails', () => {
|
||||
const tagWith = (attributes: Record<string, string>) => {
|
||||
const element = document.createElement('sasjs')
|
||||
|
||||
Object.entries(attributes).forEach(([name, value]) =>
|
||||
element.setAttribute(name, value)
|
||||
)
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
it('reads the tag attributes as name/value pairs', () => {
|
||||
const details = getSasjsTagDetails(
|
||||
tagWith({ serverType: 'SASVIYA', debug: 'false', useComputeApi: 'true' })
|
||||
)
|
||||
|
||||
expect(details).toEqual([
|
||||
{ name: 'serverType', value: 'SASVIYA' },
|
||||
{ name: 'debug', value: 'false' },
|
||||
{ name: 'useComputeApi', value: 'true' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps attributes the parser does not know about', () => {
|
||||
expect(getSasjsTagDetails(tagWith({ serverTyp: 'SASJS' }))).toEqual([
|
||||
{ name: 'servertyp', value: 'SASJS' }
|
||||
])
|
||||
})
|
||||
|
||||
it('shows every attribute, including the admin group and the licence key', () => {
|
||||
const details = getSasjsTagDetails(
|
||||
tagWith({
|
||||
serverType: 'SASJS',
|
||||
adminGroup: 'SASAdministrators',
|
||||
hotLicenceKey: 'non-commercial-and-evaluation'
|
||||
})
|
||||
)
|
||||
|
||||
expect(details).toEqual([
|
||||
{ name: 'serverType', value: 'SASJS' },
|
||||
{ name: 'adminGroup', value: 'SASAdministrators' },
|
||||
{ name: 'hotLicenceKey', value: 'non-commercial-and-evaluation' }
|
||||
])
|
||||
})
|
||||
|
||||
it('flags attributes that carry no value', () => {
|
||||
expect(getSasjsTagDetails(tagWith({ runAsTask: '' }))).toEqual([
|
||||
{ name: 'runAsTask', value: '(empty)' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -3,18 +3,64 @@ import { BehaviorSubject } from 'rxjs'
|
||||
|
||||
export type StartupStepStatus = 'pending' | 'in_progress' | 'done' | 'error'
|
||||
|
||||
/** A name/value pair shown under a startup step. */
|
||||
export interface StartupStepDetail {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface StartupStep {
|
||||
label: string
|
||||
status: StartupStepStatus
|
||||
detail?: string
|
||||
/** Rendered as a grid under the label. */
|
||||
details?: StartupStepDetail[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the `<sasjs>` tag dump within the startup steps. It is appended
|
||||
* after the three startup checks so the indices the startup flow already drives
|
||||
* (sas.service / app.service) are unchanged.
|
||||
*/
|
||||
export const SASJS_TAG_STEP = 3
|
||||
|
||||
/**
|
||||
* Canonical spelling of the `<sasjs>` tag attributes. HTML lower-cases attribute
|
||||
* names in the DOM, so they are mapped back for display.
|
||||
*/
|
||||
const TAG_ATTRIBUTE_NAMES: Record<string, string> = {
|
||||
serverurl: 'serverUrl',
|
||||
apploc: 'appLoc',
|
||||
servertype: 'serverType',
|
||||
loginmechanism: 'loginMechanism',
|
||||
admingroup: 'adminGroup',
|
||||
dcpath: 'dcPath',
|
||||
debug: 'debug',
|
||||
usecomputeapi: 'useComputeApi',
|
||||
runastask: 'runAsTask',
|
||||
contextname: 'contextName',
|
||||
hotlicencekey: 'hotLicenceKey'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the attributes of the `<sasjs>` tag for display in the startup checks.
|
||||
* Every attribute is shown - including ones the parser does not know about, so a
|
||||
* misspelt one is visible and a wrong value is exactly the misconfiguration this
|
||||
* dump exists to expose.
|
||||
*/
|
||||
export const getSasjsTagDetails = (element: Element): StartupStepDetail[] =>
|
||||
Array.from(element.attributes).map((attribute) => ({
|
||||
name: TAG_ATTRIBUTE_NAMES[attribute.name.toLowerCase()] || attribute.name,
|
||||
value: attribute.value || '(empty)'
|
||||
}))
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StartupCheckService {
|
||||
private steps: StartupStep[] = [
|
||||
{ label: 'Checking app location', status: 'pending' },
|
||||
{ label: 'Checking Viya deploy', status: 'pending' },
|
||||
{ label: 'Loading startup service', status: 'pending' }
|
||||
{ label: 'Loading startup service', status: 'pending' },
|
||||
{ label: 'SASjs tag', status: 'pending' }
|
||||
]
|
||||
|
||||
public steps$ = new BehaviorSubject<StartupStep[]>([...this.steps])
|
||||
@@ -30,6 +76,16 @@ export class StartupCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the `<sasjs>` tag contents on the boot screen. */
|
||||
public setTagDetails(details: StartupStepDetail[]) {
|
||||
this.steps[SASJS_TAG_STEP] = {
|
||||
...this.steps[SASJS_TAG_STEP],
|
||||
status: 'done',
|
||||
details
|
||||
}
|
||||
this.steps$.next([...this.steps])
|
||||
}
|
||||
|
||||
public setSteps(steps: StartupStep[]) {
|
||||
this.steps = steps
|
||||
this.steps$.next([...this.steps])
|
||||
|
||||
@@ -138,13 +138,38 @@ export class VaMessagingService {
|
||||
this.earlyDrained = true
|
||||
const captured = (window as unknown as { __vaLastMessage?: any })
|
||||
.__vaLastMessage
|
||||
const parsed = this.parseData(captured && captured.data)
|
||||
// The early listener (va-early.js) captures from any origin, so re-apply
|
||||
// the live-path trust rule here before acting on it: only replay a message
|
||||
// that came from our own origin or from the frame that embedded us (whose
|
||||
// URL is document.referrer). Without this, a same-origin sibling frame
|
||||
// could inject a crafted DDC message that the live isTrustedSource check
|
||||
// would have rejected.
|
||||
if (!captured || typeof captured.origin !== 'string') return
|
||||
if (!this.isTrustedEarlyOrigin(captured.origin)) return
|
||||
const parsed = this.parseData(captured.data)
|
||||
if (!parsed) return
|
||||
this.resultName = parsed.resultName
|
||||
if (captured.origin) this.parentOrigin = captured.origin
|
||||
this.parentOrigin = captured.origin
|
||||
callback(parsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin check for the pre-bootstrap replay. Mirrors isTrustedSource: the
|
||||
* live path trusts a message whose event.source IS the parent frame; for a
|
||||
* captured message we cannot reference its source Window, so we trust an
|
||||
* origin that is this window's origin or the embedding frame's origin
|
||||
* (document.referrer). Unverifiable/absent referrer -> reject.
|
||||
*/
|
||||
private isTrustedEarlyOrigin(origin: string): boolean {
|
||||
if (origin === window.location.origin) return true
|
||||
if (!document.referrer) return false
|
||||
try {
|
||||
return new URL(document.referrer).origin === origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw window MessageEvent into a VaMessage, or null when it is not a
|
||||
* recognisable DDC message (e.g. unrelated postMessage traffic).
|
||||
|
||||
@@ -92,14 +92,49 @@ export class DcValidator {
|
||||
this.rules.push({ ...EDIT_STATUS_COLUMN_RULE })
|
||||
this.hiddenColumns.push(this.rules.length - 1)
|
||||
|
||||
this.dqrules = dqRules
|
||||
this.dqrules = [...dqRules]
|
||||
this.dqdata = dqData
|
||||
this.primaryKeys = sasparams.PK.split(' ')
|
||||
|
||||
// A primary key is NOT NULL by definition, so it must reject a blank and
|
||||
// a special missing (".A"-".Z", "._") even when the target table carries
|
||||
// no physical NOT NULL constraint and MPE_VALIDATIONS has no NOTNULL rule
|
||||
// for it. Synthesised here so every keyed table gets it, and so the grid,
|
||||
// the edit-record modal and Excel upload validation all see the same rule.
|
||||
this.addPrimaryKeyNotNullRules()
|
||||
|
||||
this.updateDqData()
|
||||
this.setupValidations()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a NOTNULL rule for each primary key column that does not already
|
||||
* have one. A primary key identifies the row, so a blank or a special
|
||||
* missing there is never valid.
|
||||
*/
|
||||
private addPrimaryKeyNotNullRules(): void {
|
||||
for (const pk of this.primaryKeys) {
|
||||
if (!pk) continue
|
||||
|
||||
// A buskey can name a column the table no longer has - do not
|
||||
// synthesise a rule for a column that is not in the grid.
|
||||
if (!this.rules.some((rule) => rule.data === pk)) continue
|
||||
|
||||
const hasNotNull = this.dqrules.some(
|
||||
(rule) => rule.BASE_COL === pk && rule.RULE_TYPE === 'NOTNULL'
|
||||
)
|
||||
|
||||
if (!hasNotNull) {
|
||||
this.dqrules.push({
|
||||
BASE_COL: pk,
|
||||
RULE_TYPE: 'NOTNULL',
|
||||
RULE_VALUE: '',
|
||||
X: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerCustomEditors() {
|
||||
Handsontable.editors.registerEditor(
|
||||
'autocomplete.custom',
|
||||
@@ -283,7 +318,14 @@ export class DcValidator {
|
||||
* So we will convert it before pushing to array.
|
||||
*/
|
||||
if (rule.type && rule.type === 'numeric') {
|
||||
details.push(Number(data['RULE_DATA']))
|
||||
// A special missing reaches us as a bare letter ("A", "_"), and
|
||||
// the regular missing as "." - Number() would turn either into
|
||||
// NaN, so a strict (HARDSELECT) dropdown could never accept a
|
||||
// value the column actually holds. Keep them as they are.
|
||||
const rawValue = data['RULE_DATA']
|
||||
details.push(
|
||||
isSpecialMissing(rawValue) ? rawValue : Number(rawValue)
|
||||
)
|
||||
} else {
|
||||
details.push(data['RULE_DATA'])
|
||||
}
|
||||
|
||||
@@ -118,7 +118,12 @@ describe('DC Validator', () => {
|
||||
expect(dcValidator.getRule('SOME_TIME')).toBeUndefined()
|
||||
|
||||
// Test data quality functions
|
||||
expect(dcValidator.getDqDetails()).toHaveSize(dqRules.length)
|
||||
// dqRules + 2 synthesised rules: the SOFTSELECT rule updateDqData()
|
||||
// derives for SOME_DROPDOWN out of dqdata, and the NOTNULL rule
|
||||
// addPrimaryKeyNotNullRules() gives the primary key column (example_dqRules
|
||||
// has none for it). Note the constructor copies dqRules rather than
|
||||
// aliasing it, so this array is no longer mutated by construction.
|
||||
expect(dcValidator.getDqDetails()).toHaveSize(dqRules.length + 2)
|
||||
expect(dcValidator.getDqDetails('non_existant')).toHaveSize(0)
|
||||
expect(dcValidator.getDqDetails('SOME_NUM')).toHaveSize(2)
|
||||
expect(dcValidator.isDqCol('SOME_NUM')).toBeTrue()
|
||||
@@ -133,6 +138,91 @@ describe('DC Validator', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('treats the primary key column as NOT NULL, even with no NOTNULL rule configured', () => {
|
||||
const sasparams: SASParam = example_sasparams
|
||||
const cols: Col[] = example_cols
|
||||
const dqRules: DQRule[] = example_dqRules // no NOTNULL for PRIMARY_KEY_FIELD
|
||||
const dqData: DQData[] = example_dqData
|
||||
const $dataFormats: $DataFormats = example_dataformats
|
||||
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
sasparams,
|
||||
$dataFormats,
|
||||
cols,
|
||||
dqRules,
|
||||
dqData
|
||||
)
|
||||
|
||||
const pkRules = dcValidator.getDqDetails('PRIMARY_KEY_FIELD')
|
||||
expect(pkRules.some((rule) => rule.RULE_TYPE === 'NOTNULL')).toBeTrue()
|
||||
|
||||
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
|
||||
|
||||
// A primary key identifies the row, so neither a blank nor a special
|
||||
// missing can satisfy it.
|
||||
dcValidator.executeHotValidator(pkRule!, null, (valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
dcValidator.executeHotValidator(pkRule!, 'A', (valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
dcValidator.executeHotValidator(pkRule!, 5, (valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a special missing in a numeric dropdown source, so a strict rule can match it', () => {
|
||||
const dqData: DQData[] = [
|
||||
{
|
||||
BASE_COL: 'SOME_NUM',
|
||||
RULE_VALUE: 'SOME_NUM',
|
||||
RULE_DATA: 1,
|
||||
SELECTBOX_ORDER: 1
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_NUM',
|
||||
RULE_VALUE: 'SOME_NUM',
|
||||
RULE_DATA: 'A',
|
||||
SELECTBOX_ORDER: 2
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_NUM',
|
||||
RULE_VALUE: 'SOME_NUM',
|
||||
RULE_DATA: '_',
|
||||
SELECTBOX_ORDER: 3
|
||||
},
|
||||
{
|
||||
BASE_COL: 'SOME_NUM',
|
||||
RULE_VALUE: 'SOME_NUM',
|
||||
RULE_DATA: '.',
|
||||
SELECTBOX_ORDER: 4
|
||||
}
|
||||
] as DQData[]
|
||||
|
||||
const dcValidator: DcValidator = new DcValidator(
|
||||
example_sasparams,
|
||||
example_dataformats,
|
||||
example_cols,
|
||||
example_dqRules,
|
||||
dqData
|
||||
)
|
||||
|
||||
// SOME_NUM is numeric and carries a HARDSELECT_HOOK, so its dropdown
|
||||
// source comes from dqdata. Number() would have NaN'd the special
|
||||
// missings, leaving the strict membership test unable to match a value
|
||||
// the column actually holds.
|
||||
const source = dcValidator.getDqDropdownSource(
|
||||
dcValidator.getRule('SOME_NUM')!
|
||||
)
|
||||
|
||||
expect(source[0]).toEqual(1)
|
||||
expect(source[1]).toEqual('A')
|
||||
expect(source[2]).toEqual('_')
|
||||
// the regular missing is a dropdown option too, not a NaN
|
||||
expect(source[3]).toEqual('.')
|
||||
expect(source.some((value) => Number.isNaN(value as number))).toBeFalse()
|
||||
})
|
||||
|
||||
it('should test hot validator', () => {
|
||||
const sasparams: SASParam = example_sasparams
|
||||
const cols: Col[] = example_cols
|
||||
@@ -163,21 +253,22 @@ describe('DC Validator', () => {
|
||||
dcValidator.executeHotValidator(someNumRule!, 'ss', (valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
//Special missings
|
||||
// Special missings - a SAS NOT NULL (or primary key) constraint rejects
|
||||
// these, so the rule must reject them too: they are NULL, not values.
|
||||
dcValidator.executeHotValidator(someNumRule!, 's', (valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
dcValidator.executeHotValidator(someNumRule!, '.s', (valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
dcValidator.executeHotValidator(someNumRule!, '.', (valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
dcValidator.executeHotValidator(someNumRule!, '..', (valid: boolean) => {
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
dcValidator.executeHotValidator(someNumRule!, '._', (valid: boolean) => {
|
||||
expect(valid).toBeTrue()
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
|
||||
// MINVAL, MAXVAL Validation
|
||||
@@ -193,7 +284,7 @@ describe('DC Validator', () => {
|
||||
expect(valid).toBeFalse()
|
||||
})
|
||||
dcValidator.executeHotValidator(shortNumRule!, 's', (valid: boolean) => {
|
||||
expect(valid).toBeFalse() // Special missings are lowest numbers, if any MINVAL is set, special missing is always lower
|
||||
expect(valid).toBeFalse() // Special missings are the lowest numbers, so any MINVAL is above them
|
||||
})
|
||||
|
||||
// CASE validation
|
||||
@@ -462,11 +553,27 @@ describe('DC Validator', () => {
|
||||
example_dqData
|
||||
)
|
||||
|
||||
// HOT 18.1 tightened `ColumnSettings['validator']` to the real
|
||||
// `string | RegExp | function` union (18.0 erased it behind an index
|
||||
// signature), so narrow it the way the production code does before
|
||||
// invoking it.
|
||||
const validatorOf = (rule: ReturnType<DcValidator['getRule']>) => {
|
||||
const validator = rule?.validator
|
||||
if (typeof validator !== 'function') {
|
||||
throw new Error('expected the rule to carry a function validator')
|
||||
}
|
||||
return validator as (
|
||||
this: any,
|
||||
value: any,
|
||||
callback: (valid: boolean) => void
|
||||
) => void
|
||||
}
|
||||
|
||||
it('exempts an invalid non-PK cell on a delete-marked row', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
validatorOf(someNumRule).call(
|
||||
fakeCellProps(true),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
@@ -479,7 +586,7 @@ describe('DC Validator', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
validatorOf(someNumRule).call(
|
||||
fakeCellProps(true),
|
||||
null,
|
||||
(valid: boolean) => {
|
||||
@@ -492,7 +599,7 @@ describe('DC Validator', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
validatorOf(someNumRule).call(
|
||||
fakeCellProps(false),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
@@ -505,7 +612,7 @@ describe('DC Validator', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const someNumRule = dcValidator.getRule('SOME_NUM')
|
||||
|
||||
someNumRule!.validator!.call(
|
||||
validatorOf(someNumRule).call(
|
||||
fakeCellProps(false),
|
||||
2,
|
||||
(valid: boolean) => {
|
||||
@@ -518,7 +625,7 @@ describe('DC Validator', () => {
|
||||
const dcValidator = buildValidator()
|
||||
const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')
|
||||
|
||||
pkRule!.validator!.call(
|
||||
validatorOf(pkRule).call(
|
||||
fakeCellProps(true),
|
||||
'not a number',
|
||||
(valid: boolean) => {
|
||||
|
||||
@@ -24,7 +24,44 @@ describe('DC Validator - dq validation', () => {
|
||||
expect(dqValidate(dqRules, invalidValue)).toBeFalse()
|
||||
expect(dqValidate(dqRules, invalidStringValue)).toBeFalse()
|
||||
expect(dqValidate(dqRules, numericStringValue)).toBeTrue()
|
||||
// A missing sorts below every number, so it is below the floor.
|
||||
expect(dqValidate(dqRules, numericSpecialMissingValue)).toBeFalse()
|
||||
expect(dqValidate(dqRules, null)).toBeFalse()
|
||||
expect(dqValidate(dqRules, undefined)).toBeFalse()
|
||||
})
|
||||
|
||||
it('should order the missing values in a range rule as SAS does', () => {
|
||||
const missingRange: DQRule[] = [
|
||||
{ BASE_COL: 'test', RULE_TYPE: 'MINVAL', RULE_VALUE: '.A', X: 0 },
|
||||
{ BASE_COL: 'test', RULE_TYPE: 'MAXVAL', RULE_VALUE: '.C', X: 0 }
|
||||
]
|
||||
|
||||
// Inside the range of missings
|
||||
expect(dqValidate(missingRange, '.A')).toBeTrue()
|
||||
expect(dqValidate(missingRange, '.B')).toBeTrue()
|
||||
expect(dqValidate(missingRange, '.C')).toBeTrue()
|
||||
expect(dqValidate(missingRange, 'b')).toBeTrue()
|
||||
|
||||
// Outside it - above the ceiling
|
||||
expect(dqValidate(missingRange, '.D')).toBeFalse()
|
||||
expect(dqValidate(missingRange, 'z')).toBeFalse()
|
||||
|
||||
// Outside it - below the floor. The regular missing sits between ._ and .A
|
||||
expect(dqValidate(missingRange, '._')).toBeFalse()
|
||||
expect(dqValidate(missingRange, null)).toBeFalse()
|
||||
|
||||
// Every number sorts above every missing, so it is above the ceiling
|
||||
expect(dqValidate(missingRange, 0)).toBeFalse()
|
||||
expect(dqValidate(missingRange, 5)).toBeFalse()
|
||||
|
||||
// A floor of .A alone still lets the numbers through: they are above it
|
||||
const missingFloor: DQRule[] = [
|
||||
{ BASE_COL: 'test', RULE_TYPE: 'MINVAL', RULE_VALUE: '.A', X: 0 }
|
||||
]
|
||||
expect(dqValidate(missingFloor, '.A')).toBeTrue()
|
||||
expect(dqValidate(missingFloor, '.Z')).toBeTrue()
|
||||
expect(dqValidate(missingFloor, 5)).toBeTrue()
|
||||
expect(dqValidate(missingFloor, '._')).toBeFalse()
|
||||
})
|
||||
|
||||
it('should validate MAXVAL value', () => {
|
||||
@@ -49,7 +86,10 @@ describe('DC Validator - dq validation', () => {
|
||||
expect(dqValidate(dqRules, invalidValue)).toBeFalse()
|
||||
expect(dqValidate(dqRules, invalidStringValue)).toBeFalse()
|
||||
expect(dqValidate(dqRules, numericStringValue)).toBeTrue()
|
||||
// A missing sorts below every number, so it is below the ceiling
|
||||
expect(dqValidate(dqRules, numericSpecialMissingValue)).toBeTrue()
|
||||
expect(dqValidate(dqRules, null)).toBeTrue()
|
||||
expect(dqValidate(dqRules, undefined)).toBeTrue()
|
||||
})
|
||||
|
||||
it('should validate UPCASE value', () => {
|
||||
@@ -111,6 +151,43 @@ describe('DC Validator - dq validation', () => {
|
||||
expect(dqValidate(dqRules, invalidValue2)).toBeFalse()
|
||||
})
|
||||
|
||||
it('should reject a special missing on a numeric column (a SAS NOT NULL constraint does)', () => {
|
||||
const dqRules: DQRule[] = [
|
||||
{
|
||||
BASE_COL: 'test',
|
||||
RULE_TYPE: 'NOTNULL',
|
||||
RULE_VALUE: ' ',
|
||||
X: 0
|
||||
}
|
||||
]
|
||||
|
||||
// The values a real SAS service delivers for a numeric column.
|
||||
expect(dqValidate(dqRules, 'A', true)).toBeFalse()
|
||||
expect(dqValidate(dqRules, '_', true)).toBeFalse()
|
||||
expect(dqValidate(dqRules, '.', true)).toBeFalse()
|
||||
// Ordinary numbers and numeric strings still pass.
|
||||
expect(dqValidate(dqRules, 5, true)).toBeTrue()
|
||||
expect(dqValidate(dqRules, '5', true)).toBeTrue()
|
||||
})
|
||||
|
||||
it('should not reject a single letter on a character column', () => {
|
||||
const dqRules: DQRule[] = [
|
||||
{
|
||||
BASE_COL: 'test',
|
||||
RULE_TYPE: 'NOTNULL',
|
||||
RULE_VALUE: ' ',
|
||||
X: 0
|
||||
}
|
||||
]
|
||||
|
||||
// There is no special-missing concept on a character column - a lone
|
||||
// letter is ordinary data.
|
||||
expect(dqValidate(dqRules, 'A')).toBeTrue()
|
||||
expect(dqValidate(dqRules, '_')).toBeTrue()
|
||||
expect(dqValidate(dqRules, '.')).toBeTrue()
|
||||
expect(dqValidate(dqRules, '', false)).toBeFalse()
|
||||
})
|
||||
|
||||
it('should return true if rule not found', () => {
|
||||
const validValue = 5
|
||||
|
||||
|
||||
@@ -1,8 +1,49 @@
|
||||
import { DQRule } from '../models/dq-rules.model'
|
||||
import { specialMissingNumericValidator } from './hot-custom-validators'
|
||||
import { isSpecialMissing } from '@sasjs/utils/input/validators'
|
||||
import { isRegexRuleExempt } from '../utils/isRegexRuleExempt'
|
||||
import { parseRegexRule } from '../utils/parseRegexRule'
|
||||
|
||||
/**
|
||||
* A SAS numeric variable's values have a total order, and its missing values sit
|
||||
* below every non-missing value. The missing values are themselves ordered:
|
||||
* `._` is the lowest, then the regular missing, then `.A` through `.Z`.
|
||||
*
|
||||
* A range rule compares in that order. That is what makes a range of missings
|
||||
* meaningful - with `MINVAL .A` and `MAXVAL .C`, `.B` is inside the range and `.D`
|
||||
* is outside it - and it is also why a special missing fails a numeric floor: it
|
||||
* sorts below every number.
|
||||
*
|
||||
* The key is a pair: the class (0 for a missing, 1 for a number, so that every
|
||||
* number sorts above every missing) and the position within that class. A value
|
||||
* that is neither a number nor a missing has no place in the order and gets null,
|
||||
* which no rule accepts.
|
||||
*/
|
||||
const sasNumericOrderKey = (value: any): [number, number] | null => {
|
||||
if (value === undefined || value === null || value === '') return [0, 1] // regular missing
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const upper = value.trim().toUpperCase()
|
||||
|
||||
if (upper === '.' || upper === '') return [0, 1] // regular missing
|
||||
if (upper === '._' || upper === '_') return [0, 0] // the lowest missing
|
||||
if (/^\.?[A-Z]$/.test(upper))
|
||||
return [0, 2 + (upper.charCodeAt(upper.length - 1) - 65)] // .A .. .Z
|
||||
|
||||
const numValue = parseFloat(upper)
|
||||
|
||||
return isNaN(numValue) ? null : [1, numValue]
|
||||
}
|
||||
|
||||
const numValue = Number(value)
|
||||
|
||||
return isNaN(numValue) ? null : [1, numValue]
|
||||
}
|
||||
|
||||
const compareSasNumericOrder = (
|
||||
a: [number, number],
|
||||
b: [number, number]
|
||||
): number => (a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1])
|
||||
|
||||
const dqValidation: {
|
||||
[key: string]: (
|
||||
value: any,
|
||||
@@ -33,25 +74,39 @@ const dqValidation: {
|
||||
return true
|
||||
},
|
||||
MINVAL: (value: any, ruleValue: string | number): boolean => {
|
||||
const isValidNumeric = specialMissingNumericValidator(value)
|
||||
const numValue = parseFloat(value)
|
||||
const valueKey = sasNumericOrderKey(value)
|
||||
const ruleKey = sasNumericOrderKey(ruleValue)
|
||||
|
||||
// If it's validNumeric and it is NaN it means it is special numeric, and those are always less then any
|
||||
// min value set
|
||||
if (isValidNumeric && isNaN(numValue)) return false
|
||||
// A value that is neither a number nor a missing has no place in the order,
|
||||
// so nothing satisfies the rule.
|
||||
if (!valueKey || !ruleKey) return false
|
||||
|
||||
return numValue >= Number(ruleValue.toString())
|
||||
return compareSasNumericOrder(valueKey, ruleKey) >= 0
|
||||
},
|
||||
MAXVAL: (value: any, ruleValue: string | number): boolean => {
|
||||
const isValidNumeric = specialMissingNumericValidator(value)
|
||||
const numValue = parseFloat(value)
|
||||
const valueKey = sasNumericOrderKey(value)
|
||||
const ruleKey = sasNumericOrderKey(ruleValue)
|
||||
|
||||
if (isValidNumeric && isNaN(numValue)) return true
|
||||
if (!valueKey || !ruleKey) return false
|
||||
|
||||
return numValue <= Number(ruleValue.toString())
|
||||
return compareSasNumericOrder(valueKey, ruleKey) <= 0
|
||||
},
|
||||
NOTNULL: (value: any, ruleValue: string | number): boolean => {
|
||||
return value !== undefined && value !== null && value.toString().length > 0
|
||||
NOTNULL: (
|
||||
value: any,
|
||||
ruleValue: string | number,
|
||||
isNumeric: boolean = false
|
||||
): boolean => {
|
||||
if (value === undefined || value === null) return false
|
||||
|
||||
// A special missing (.A-.Z, ._) is NULL as far as a SAS NOT NULL (or
|
||||
// primary key) constraint is concerned - an insert carrying one is
|
||||
// rejected with an integrity constraint error - so the rule must reject
|
||||
// it too, or the editor would accept a value the target table refuses.
|
||||
// Numeric columns only: a lone letter is ordinary data on a character
|
||||
// column.
|
||||
if (isNumeric && isSpecialMissing(value)) return false
|
||||
|
||||
return value.toString().length > 0
|
||||
},
|
||||
// Pattern is used as authored, not auto-anchored — a rule author who
|
||||
// wants a full-value match must write ^...$ themselves.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SidebarComponent } from './sidebar.component'
|
||||
|
||||
describe('SidebarComponent', () => {
|
||||
// getSubPage is a pure read of the router's current URL, and the only other
|
||||
// dependencies are the two SasService calls in the constructor - so the
|
||||
// class can be constructed directly, without a TestBed.
|
||||
const buildComponent = (routerUrl: string) =>
|
||||
new SidebarComponent(
|
||||
{ url: routerUrl } as any,
|
||||
{} as any,
|
||||
{
|
||||
getSasjsConfig: () => ({}),
|
||||
getServerType: () => 'SASVIYA'
|
||||
} as any
|
||||
)
|
||||
|
||||
describe('getSubPage', () => {
|
||||
it('returns the sub-page segment of a plain route', () => {
|
||||
expect(buildComponent('/home/tables').getSubPage()).toEqual('tables')
|
||||
})
|
||||
|
||||
it('excludes the query string - a VA report embed always adds one', () => {
|
||||
expect(buildComponent('/home/tables?embed=va').getSubPage()).toEqual(
|
||||
'tables'
|
||||
)
|
||||
})
|
||||
|
||||
it('excludes the query string on the viewer route', () => {
|
||||
expect(buildComponent('/view/data?labels=true').getSubPage()).toEqual(
|
||||
'data'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -67,7 +67,10 @@ export class SidebarComponent implements OnInit {
|
||||
}
|
||||
|
||||
public getSubPage() {
|
||||
let url = this._router.url.split('/')
|
||||
// `Router.url` carries the query string, so take the path segment only -
|
||||
// otherwise any route with a parameter renders it as part of the label
|
||||
// (a VA report embed always adds one, e.g. `?embed=va`).
|
||||
let url = this._router.url.split('?')[0].split('/')
|
||||
|
||||
return url[2]
|
||||
}
|
||||
|
||||
@@ -149,3 +149,75 @@ describe('buildColInfoHtml', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* DOM-injection reproduction for the column-info dropdown.
|
||||
* buildColInfoHtml interpolates server/DB-controlled values (column label,
|
||||
* format, and DQ RULE_VALUE regex/formula strings) into a string that the
|
||||
* viewer/editor assign to raw DOM `elem.innerHTML` - so a value containing
|
||||
* markup (e.g. a HARDREGEX RULE_VALUE of `<img src=x onerror=alert(1)>`) is
|
||||
* parsed and executed in the browser of whoever opens the info dropdown.
|
||||
* These tests fail on the vulnerable implementation and pass once each field
|
||||
* is escaped.
|
||||
*/
|
||||
describe('buildColInfoHtml escapes rather than injecting raw HTML', () => {
|
||||
const malicious = '<img src=x onerror=alert(1)>'
|
||||
|
||||
const info: DataFormat = {
|
||||
format: malicious,
|
||||
label: malicious,
|
||||
length: '8',
|
||||
type: 'N'
|
||||
}
|
||||
|
||||
// Parse the returned string the same way the callers do (innerHTML on a
|
||||
// real element) and assert no scriptable element survived.
|
||||
const parseInto = (html: string): HTMLElement => {
|
||||
const host = document.createElement('div')
|
||||
host.innerHTML = html
|
||||
return host
|
||||
}
|
||||
|
||||
const assertNoInjectedElement = (html: string) => {
|
||||
const host = parseInto(html)
|
||||
expect(host.querySelector('img[onerror]')).toBeNull()
|
||||
host.remove()
|
||||
}
|
||||
|
||||
it('is inert for a colInfo whose label and format carry markup', () => {
|
||||
assertNoInjectedElement(buildColInfoHtml('SOMECHAR', info))
|
||||
})
|
||||
|
||||
it('escapes the column NAME', () => {
|
||||
const html = buildColInfoHtml(malicious, {
|
||||
format: '$8.',
|
||||
label: 'safe',
|
||||
length: '8',
|
||||
type: 'C'
|
||||
})
|
||||
// < and > must not survive as markup in the NAME position
|
||||
expect(html).not.toContain(malicious)
|
||||
assertNoInjectedElement(html)
|
||||
})
|
||||
|
||||
it('escapes a HARDREGEX RULE_VALUE', () => {
|
||||
assertNoInjectedElement(
|
||||
buildColInfoHtml('SOMECHAR', info, malicious, undefined, undefined)
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes a SOFTREGEX RULE_VALUE', () => {
|
||||
assertNoInjectedElement(
|
||||
buildColInfoHtml('SOMECHAR', info, undefined, malicious, undefined)
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes a formula RULE_VALUE (with and without a leading =)', () => {
|
||||
assertNoInjectedElement(
|
||||
buildColInfoHtml('SOMECHAR', info, undefined, undefined, malicious)
|
||||
)
|
||||
assertNoInjectedElement(
|
||||
buildColInfoHtml('SOMECHAR', info, undefined, undefined, `=${malicious}`)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,35 @@
|
||||
import { DataFormat } from '../../models/sas/common/DateFormat'
|
||||
|
||||
/**
|
||||
* Returns string-safe text of any value so it can be concatenated into a
|
||||
* string that is later assigned to raw DOM innerHTML. The column metadata
|
||||
* (label/format) and DQ RULE_VALUE strings (regex/formula) are DB-controlled -
|
||||
* a validation-rule author can store markup such as
|
||||
* `<img src=x onerror=...>` in a HARDREGEX value or a column label - so they
|
||||
* must never be parsed as HTML by the browser. Escaping turns any embedded
|
||||
* markup into inert text.
|
||||
*/
|
||||
const escapeHtml = (value: any): string =>
|
||||
String(value ?? '').replace(/[&<>"']/g, (char) => {
|
||||
const entities: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return entities[char]
|
||||
})
|
||||
|
||||
/**
|
||||
* Builds the HTML shown in a column-header "info" dropdown item (viewer and
|
||||
* editor). NAME is listed first so it's visible regardless of whether
|
||||
* headers are currently displayed as NAME or LABEL.
|
||||
*
|
||||
* The returned string is assigned to raw DOM `elem.innerHTML` by both callers
|
||||
* (viewer.component.ts / editor.component.ts) - every field interpolated below
|
||||
* is therefore escaped via escapeHtml, since no Angular sanitizer runs on a
|
||||
* raw innerHTML assignment.
|
||||
*/
|
||||
export function buildColInfoHtml(
|
||||
colName: string,
|
||||
@@ -14,16 +40,16 @@ export function buildColInfoHtml(
|
||||
): string {
|
||||
if (!colInfo) return 'No info found'
|
||||
|
||||
let html = `NAME: ${colName}<br>LABEL: ${colInfo.label}<br>TYPE: ${colInfo.type}<br>LENGTH: ${colInfo.length}<br>FORMAT: ${colInfo.format}`
|
||||
let html = `NAME: ${escapeHtml(colName)}<br>LABEL: ${escapeHtml(colInfo.label)}<br>TYPE: ${escapeHtml(colInfo.type)}<br>LENGTH: ${escapeHtml(colInfo.length)}<br>FORMAT: ${escapeHtml(colInfo.format)}`
|
||||
|
||||
// Only ever one REGEX rule is applied per column: when both HARDREGEX
|
||||
// and SOFTREGEX exist, SOFTREGEX is ignored entirely (same precedence as
|
||||
// makeRegexWarningRenderer / DcValidator.failsSoftRegex). Show only the
|
||||
// rule that is applied.
|
||||
if (hardRegexValue) {
|
||||
html += `<br>HARDREGEX: ${hardRegexValue}`
|
||||
html += `<br>HARDREGEX: ${escapeHtml(hardRegexValue)}`
|
||||
} else if (softRegexValue) {
|
||||
html += `<br>SOFTREGEX: ${softRegexValue}`
|
||||
html += `<br>SOFTREGEX: ${escapeHtml(softRegexValue)}`
|
||||
}
|
||||
|
||||
// '√x=' stands in for a text label here - HARDFORMULA vs SOFTFORMULA is
|
||||
@@ -36,7 +62,7 @@ export function buildColInfoHtml(
|
||||
const formula = formulaValue.startsWith('=')
|
||||
? formulaValue.slice(1)
|
||||
: formulaValue
|
||||
html += `<br>√x=${formula}`
|
||||
html += `<br>√x=${escapeHtml(formula)}`
|
||||
}
|
||||
|
||||
return html
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { parseUserAgent } from './parse-user-agent'
|
||||
|
||||
/**
|
||||
* Table-driven: the point of the parser is the token ORDER, and the only way
|
||||
* to pin an order is to feed it real user agent strings that carry several
|
||||
* tokens at once. Every case below is a verbatim UA from the product named
|
||||
* (the iOS ones are the ones that used to fall through to Safari).
|
||||
*/
|
||||
describe('parseUserAgent', () => {
|
||||
const cases: Array<{
|
||||
label: string
|
||||
ua: string
|
||||
browser: string
|
||||
version: string
|
||||
platform: string
|
||||
}> = [
|
||||
{
|
||||
label: 'desktop Edge (Edg)',
|
||||
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.2592.87',
|
||||
browser: 'Edge',
|
||||
version: '126.0.2592.87',
|
||||
platform: 'Windows'
|
||||
},
|
||||
{
|
||||
label: 'desktop Edge (legacy EdgeHTML)',
|
||||
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763',
|
||||
browser: 'Edge',
|
||||
version: '18.17763',
|
||||
platform: 'Windows'
|
||||
},
|
||||
{
|
||||
label: 'desktop Opera (OPR)',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 OPR/111.0.0.0',
|
||||
browser: 'Opera',
|
||||
version: '111.0.0.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Firefox',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0',
|
||||
browser: 'Firefox',
|
||||
version: '140.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Chrome',
|
||||
ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.0.0',
|
||||
platform: 'Linux'
|
||||
},
|
||||
{
|
||||
label: 'desktop Safari',
|
||||
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||
browser: 'Safari',
|
||||
version: '17.5',
|
||||
platform: 'macOS'
|
||||
},
|
||||
{
|
||||
label: 'Android Chrome',
|
||||
ua: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.0.0',
|
||||
platform: 'Android'
|
||||
},
|
||||
{
|
||||
label: 'iOS Chrome (CriOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0.6478.153 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Chrome',
|
||||
version: '126.0.6478.153',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Firefox (FxiOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/127.0 Mobile/15E148 Safari/605.1.15',
|
||||
browser: 'Firefox',
|
||||
version: '127.0',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Edge (EdgiOS) - not Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/126.0.2592.86 Version/17.0 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Edge',
|
||||
version: '126.0.2592.86',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'iOS Safari',
|
||||
ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||
browser: 'Safari',
|
||||
version: '17.5',
|
||||
platform: 'iOS'
|
||||
},
|
||||
{
|
||||
label: 'a non-browser client',
|
||||
ua: 'curl/8.5.0',
|
||||
browser: 'Unknown',
|
||||
version: '',
|
||||
platform: 'Unknown'
|
||||
},
|
||||
{
|
||||
label: 'an empty user agent',
|
||||
ua: '',
|
||||
browser: 'Unknown',
|
||||
version: '',
|
||||
platform: 'Unknown'
|
||||
}
|
||||
]
|
||||
|
||||
cases.forEach((c) => {
|
||||
it(`reports ${c.label} as ${c.browser} ${c.version || '(no version)'} on ${c.platform}`, () => {
|
||||
expect(parseUserAgent(c.ua)).toEqual({
|
||||
browser: c.browser,
|
||||
browserVersion: c.version,
|
||||
platform: c.platform
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Summarises `navigator.userAgent` into the three things worth logging about a
|
||||
* browser: its family, its version, and the platform it is running on.
|
||||
*
|
||||
* The raw user agent is kept alongside these - it is what a support ticket
|
||||
* actually needs - but a hook is far more likely to want to branch on a name
|
||||
* than to write its own user agent parsing.
|
||||
*
|
||||
* Order matters: Edge and Opera both claim to be Chrome, and Chrome claims to
|
||||
* be Safari, so the most specific token has to be tested first. The mobile
|
||||
* tokens are the sharpest case - an iOS browser carries its own token
|
||||
* (`CriOS`, `FxiOS`, `EdgiOS`) *and* `Safari/`, so without the mobile token
|
||||
* being tested first every | ||||