Compare commits

..
Author SHA1 Message Date
dc c18cb0f720 test(security): gate the job-state abort assertions on Viya
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m7s
Build / Build-and-test-development (pull_request) Successful in 33m37s
The abort cases in getcols, getcolvals.test.4, getrawdata.test.1 and
validatefilter.test.1 assert that the child program was canceled by reading
work.results, the job table %mv_jobflow writes in the Viya branch of
%mx_execute. The SASjs Server and SAS 9 branches call %ms_testservice
instead and produce no such table, so the data step fails with "File
WORK.RESULTS.DATA does not exist.", the flag stays 0 and the assertion
reports (0=1) - a failure that says nothing about whether the service
validated its input.

Each case is now wrapped in %if %mf_getplatform()=SASVIYA, with an explicit
skip row on the other platforms, so the suite reports what it actually
measured.

Verified on SASjs Server (sas.4gl.io, target sas719): the four programs
report PASS with "skipped (the job state it reads is only written on Viya)"
for the gated cases. sasjs lint: 0 errors, no warnings in the four files.
2026-10-26 20:14:33 +00:00
428 changed files with 13852 additions and 40706 deletions

No files matched your search

@@ -1,32 +0,0 @@
# What guards the deploy window
The deploy screens ask two questions about the physical filesystem - what folders sit below this parent, and can the deployment user write to this one. Both are answered by `makedata` (`ACTION=DIRLIST` and `ACTION=WRITECHECK`), through `mpe_dirlist.sas` and `mpe_writecheck.sas`.
This note records the two-layer answer: a gate that closes the window once the appLoc is configured, and why the window itself has no group check.
## The gate
The deploy window is over once the appLoc has `services/public/settings.sas`: `makedata` writes it at the end of a successful run, and a run that failed - or was abandoned - after writing it leaves the file behind. So the service refuses `DIRLIST` and `WRITECHECK` whenever that file is present, however the run that created it ended. On SASjs Server the file is fetched with `ms_getfile` and a probe that cannot answer (anything other than 200 or 404) is treated as configured: an unreachable drive is not evidence that the window is open. Viya checks the same file through `mfv_existfile`.
The consequence for tests: the browse actions are refused on any appLoc that runs the suite, because every such appLoc is configured. `services/admin/makedata.test.sas` therefore asserts the refusal, and the listing and write-probe behaviour is asserted against the macros directly (`macros/mpe_dirlist.test.sas`, `macros/mpe_writecheck.test.sas`). The macros' own abort paths (a relative path, one carrying a macro trigger) are not asserted anywhere: `mp_abort` cannot be exercised inside the test session.
## Why there is no group check
Every other admin service gates on `&mpeadmins`, which comes from `MPE_CONFIG` in the control library - and that library is what `makedata` creates. Before it runs there is no group to check: `%mpeinit()` reads `&DC_LIBREF..mpe_config` and has nothing to read. That is also why the previous `dirlist` service could never serve the deploy screen it was written for.
The window is not narrower than the configurator's own: on SASjs Server the configurator page, in the same window, lets the caller pick any group they belong to and create the control library - which is full Data Controller admin. `DIRLIST` and `WRITECHECK` are strictly less powerful than that.
## What the actions do, for the record
- `DIRLIST` - enumerate the folders below any absolute path the SAS process can open. Folder names only; no file contents, no file listing.
- `WRITECHECK` - ask whether a path exists, is a folder, and whether the deployment user can create and delete a file in it. It creates a uniquely named probe file and removes it again.
Both refuse a value carrying a macro trigger, a quote or a statement separator, and shape check it with `mp_validatecol(ISPATH)` before use. The shape check fails closed: an absent `ISPATH` rule refuses the path rather than accepting it.
## The assessment
- **The window is the deployment itself.** `makedata` is deployed, run once by an administrator, and deletes itself on success.
- **There is no admin group to check while it runs.** It is created by `makedata`, from the group the deployer picks in the configurator.
- **The caller is the administrator.** Reaching these actions means reaching the STPs of a deployment in progress, which is the same caller the configurator is serving in the same window.
- **The actions are the weaker half of that window.** The configurator, in the same window, lets the caller pick any group they belong to and create the control library - which is full Data Controller admin. Listing folders and probing a write cannot achieve that.
- **The window closes on the settings file, not on survival of the service.** That is what covers a failed or abandoned run.
+1 -2
View File
@@ -1,11 +1,10 @@
# Testing
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory. For a deploy-and-run on the shared state see the `dc-estate-testing` skill (`.agents/skills/dc-estate-testing/SKILL.md`), which covers the credentials, the `makedata` seed the suite needs and the estate's group name.
Backend (SAS) tests are run with the sasjs CLI from the `sas/` directory.
## Commands
- Full deploy + test cycle: `npm run 4gl` (compiles, deploys to the 4gl target, runs makedata) then `sasjs test -t 4gl`.
- The shared estate under its `server` target: `npm run server` (compile, deploy, seed) then `sasjs test -t server`.
- Run tests only (no rebuild/redeploy): `sasjs test -t 4gl`.
- Run a subset of tests: `sasjs test -t 4gl SOMESTRING` — the positional argument filters to matching tests (e.g. `sasjs test -t 4gl stagedata.test.3`).
+1 -5
View File
@@ -46,12 +46,8 @@ Don't use for: SAS backend tests (`sasjs test -t 4gl`), Angular unit tests (`npm
cd sas/mocks && sasjs request services/admin/makedata -t server-ci \
-d deploy/makedata.json -o ./makedata_out.json
cd sas/mocks && sasjs request services/admin/createdemodata -t server-ci
`makedata` builds only what a SAS deployment builds (the control library);
`createdemodata` adds the Cypress fixtures and refuses to run without it.
The payload asks for `OUTFMT=JSON`, so both calls exit non-zero on a failed
seed rather than reporting `invalid Json string`.
`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.
-79
View File
@@ -1,79 +0,0 @@
---
name: dc-estate-testing
description: >
Use when deploying Data Controller to a shared SASjs Server estate (sas.4gl.io) or running the SAS
test suite against it. Covers the target and credential setup, the makedata seed the suite needs on
a fresh appLoc and the group it must name, how to read the results, and how to deploy the frontend
so the streamed app is the build you just made.
---
# Deploying and testing on the shared SASjs Server estate
The whole cycle is two npm scripts plus `sasjs test`. Everything below explains what those scripts do and the steps that go wrong.
cd sas && npm ci # macro dependencies
cd ../client && npm ci && npm run build # frontend only - see "Deploying the frontend"
cd ../sas && npm run server # cpfavicon + sasjs cbd -t server + makedata
npx sasjs test -t server
`npm run server` is `sasjs cbd -t server && npm run serverdata`, and the seed in the second half is not optional. `sasjs cbd` on its own deploys the services and the test programs but leaves the appLoc with no Data Controller configuration, and `testsetup` reads `<appLoc>/services/public/settings.sas` through the drive API. `makedata` is what creates that file, so without the seed `testsetup` gets a 404, aborts, and 59 of the 64 test programs fail to complete on setup alone - which reads like a broken deployment rather than a missing seed.
## The targets
`server` is the shared estate: `serverType: SASJS` on https://sas.4gl.io, `appLoc` `/Public/app/dc`, a real SAS 9 binary behind SASjs Server (`GET /SASjsApi/info` reports `mode: server`, `runTimes: ["sas"]`, `localLoginEnabled: true`). `4gl` is the same estate with a slightly different config, hence the older `npm run 4gl` / `4glmakedata` pair.
A run against `server` exercises the SASjs Server branch of every macro and the SAS 9 backend, and nothing else: no Viya branch, no SAS 9 STP branch. Say that when reporting, rather than implying the matrix is covered.
To deploy your own branch without replacing the app everyone else uses, add a target that copies `server` and changes `appLoc` and `streamServiceName` (for example `/Public/app/<you>/dc`), keep it out of the commit, and pass it with `-t`. `sasjs cbd` overwrites the whole appLoc it is pointed at.
## Credentials
The CLI logs in with a registered client, not a username and password: `.env.<target>` (gitignored, mode 600) needs `CLIENT`, `ACCESS_TOKEN` and `REFRESH_TOKEN`. With `CLIENT` alone it prints a `/SASjsLogon?client_id=...` URL and waits for an authorization code, which cannot be answered headless.
Mint the pair from a local login, with the password read from a file rather than argv:
1. `GET /` with a cookie jar - it sets `XSRF-TOKEN`, which every POST must echo in an `X-XSRF-TOKEN` header.
2. `POST /SASLogon/login` with `{"username","password"}`.
3. `GET /SASjsApi/client` on the same session - the registered client ids.
4. `POST /SASLogon/authorize` with `{"clientId"}` -> `{"code"}`.
5. `POST /SASjsApi/auth/token` with `{"clientId","code"}` -> `accessToken` / `refreshToken`.
An unmatched `/SASjsApi/...` path answers `200` with the Swagger page, so check the content type; HTML means the route does not exist. Delete the token file when the run is over.
## The makedata seed
`npm run serverdata` sends `deploy/makeDataServer.json`: one `fromjs` row with `ADMIN`, `DCPATH` and `OUTFMT`. Two things about it are worth knowing before you trust its result.
- **`ADMIN` must name a group the calling account is actually in.** The shipped value is `all-users`, the SASjs Server group that every logged-in user is a member of, which is what the shared estate needs. A name the account is not in - a Viya-style group such as `AllUsers`, or one estate's own group carried into another - still answers `SUCCESS` from the seed, and then every later `getdata`/`stagedata` call aborts with `<user> is not authorised to edit <lib>.<ds> in the <lib>.MPE_SECURITY table`. Check what the account belongs to with a call to `services/public/getgroups` before changing the value.
- **Each run creates a new library.** The response carries `STATUS`, `DCPATH` and `LIBREF` (for example `DC621835`), and the LIBREF is clock-derived, so a libref from an earlier run answers `dataset <lib>.<table> does not exist` - take the current one from the response.
- **`KEEPME` controls whether the seed can be repeated.** Without it `makedata` deletes itself and the configurator after a successful run (deliberately, so it cannot be executed twice over real data), and a second seed fails with `The Program at (<appLoc>/services/admin/makedata) does not exist` until you run `sasjs cbd` again. A harness that reseeds between runs sets `KEEPME` in the input row.
- **Seed once per deploy, not before every test run.** The suite builds its own `dctest` library from the seeded control tables; reseeding in between is wasted work, and there is no need to run `makedata` and `sasjs test` in the opposite order.
## Reading the results
Results land in `sas/sasjsresults/`: `testResults.json` (the counts worth quoting: `tests_with_results`, `tests_that_pass`, `failed_to_complete`, `completed_with_failures`), `testResults.csv` (one row per assertion, lower-case headers `test_target`, `test_loc`, `sasjs_test_id`, `test_suite_result`, `test_description`, `test_url`), and one SAS log per program under `logs/`. The CLI's closing lines are not the source of truth when they disagree with the JSON.
A current green run is 64 of 64 test programs with 241 assertions passing and nothing failing to complete.
`ERROR` lines in the per-program logs are expected and are not a failed run: the specs that assert a refusal get `ERROR: Execution canceled by an %ABORT CANCEL statement`, and the ones that feed bad payloads get the rejection message they are asserting on (`Invalid libds:...`, `Invalid libref:...`). Judge the run by the JSON counts, then by whether the errors you find are the ones the tests asked for.
## Deploying the frontend
The streamed web app is a separate artefact and `sasjs cbd` only streams it if `client/dist` exists:
1. `client/libraries/sheet-crypto.tgz.gpg` is an encrypted dependency (`file:libraries/sheet-crypto.tgz` in `client/package.json`) that is not on the public registry. Decrypt it with the passphrase the maintainer holds: `printf '%s' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 --output ./libraries/sheet-crypto.tgz --decrypt ./libraries/sheet-crypto.tgz.gpg`.
2. `client/.npmrc` is gitignored and needs `legacy-peer-deps=true` (the committed lock is written with it) plus the `@sheet` registry lines.
3. `cd client && npm ci && npm run build`.
4. The build carries the repo default appLoc, so point it at the deployment before deploying: rewrite `apploc="/Public/app/dc"` in `dist/index.html` to the target appLoc.
5. `cd ../sas && npx sasjs cbd -t <target>` - with `client/dist` present the log says `Copying web app files` and `Web app is available at <url>`.
Without step 1 the deploy prints `ERROR An error has occurred when compiling web app services ... webSourcePath: .../client/dist doesn't exist` and then deploys the services anyway, so the deploy looks successful while the app streams nothing. The AppStream URL is session-protected: an anonymous `curl` gets a `401`, so verify it with a logged-in cookie jar and fetch the `main.<hash>.js` the page names.
Never commit `client/.npmrc` or the decrypted tarball; both are ignored, and `git status` staying clean is the check.
## Reading a failed run
- `ERROR An error occurred while executing the request. Job location: <appLoc>/tests/...` means the response was not JSON, so the program broke before it could write results. Open `sasjsresults/logs/<suite>.log` and read the first `ERROR`; a single compile break cascades into hundreds of `ERROR 180-322: Statement is not valid` lines and the tail is noise.
- `WARNING: Apparent symbolic reference TESTLOC not resolved` in `testsetup.log`, next to a `404 Not Found` for `services/public/settings.sas`, is the missing seed described above, not a macro problem.
- `sasjs test` runs the DEPLOYED program, so after editing a service, hook or test, run `sasjs cbd` again before testing or you will debug code that is no longer in the tree.
- A deployment can succeed and still be missing its web app, its data or its configuration - check the thing you changed rather than the exit code.
-10
View File
@@ -20,16 +20,6 @@ if ! "$gitleaks_bin" protect --staged --redact; then
exit 1
fi
# Every tracked .npmrc is a policy file - root, sas/ and client/ - so a registry
# credential pasted into any of them is one `git add -A` from being committed.
# Each staged one is checked, not just the client.
for npmrc in $(git diff --cached --name-only --diff-filter=ACMR | grep -E '(^|/)\.npmrc$'); do
if git show ":$npmrc" | grep -Eq '(_authToken|_auth[^a-z]|_password|:_auth)'; then
echo "ERROR: $npmrc holds a registry credential - keep it in ~/.npmrc instead"
exit 1
fi
done
## Avoid large commits
# https://www.backblaze.com/blog/how-many-bytes-are-in-a-megabyte-really/
size_limit=$((2 * 1024 * 1024)) # 2MB
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env bash
# Fail a client job before it starts when the runner cannot hold it.
#
# Every client job here compiles the frontend from cold: `ng build
# --configuration production` peaks at 3.8 GB resident on a 4 vCPU box
# (measured with no .angular/cache present, which is what a fresh runner
# has), and the Cypress and Lighthouse jobs add a dev server and a browser on
# top of that. On a 4 GB runner the kernel OOM killer takes the build part way
# through, which surfaces as `exitcode '137'` at `ng build`, or as Karma
# disconnecting mid-suite ("no message in 30000 ms"). Both read as a flaky
# test rather than as an undersized box, and both cost a whole CI slot.
#
# 8 GB is the minimum supported runner size: it leaves roughly 2x headroom
# over that peak. This check runs before the installs, so an undersized runner
# fails in seconds with the numbers in the log.
#
# The floor is 7 GB rather than 8192 MB because an 8 GB box does not report 8
# GB: the fleet's report 7751 MB total, the rest going to firmware and the
# kernel. A 4 GB box reports under 4000 MB, so 7 GB separates the two sizes
# with room for a box that reserves a little more than ours do.
#
# What this does not catch is oversubscription: two client jobs landing on one
# box. A box that passes the floor can still OOM a build when another job is
# resident, and MemAvailable at start is the only sign of it - that is reported
# as a warning, not a failure, because a busy box is transient where a small
# box is not. Keep one client job per runner.
#
# Usage: check-runner-memory.sh [min_total_mb]
# or: MIN_TOTAL_MB=4096 check-runner-memory.sh
set -euo pipefail
MIN_TOTAL_MB="${1:-${MIN_TOTAL_MB:-7000}}"
# A typo here (8GB, a stray flag, an empty variable) would otherwise reach the
# integer test under `set -e` and abort with "integer expression expected".
case "$MIN_TOTAL_MB" in
''|*[!0-9]*)
echo "usage: check-runner-memory.sh [min_total_mb] - got '$MIN_TOTAL_MB'" >&2
exit 2
;;
esac
meminfo_field() {
awk -v key="$1:" '$1 == key { print $2 }' /proc/meminfo
}
total_kb=$(meminfo_field MemTotal || true)
avail_kb=$(meminfo_field MemAvailable || true)
cpus=$(nproc 2>/dev/null || echo '?')
if [ -z "$total_kb" ]; then
echo "runner memory: /proc/meminfo has no MemTotal - cannot measure, continuing"
exit 0
fi
total_mb=$(( total_kb / 1024 ))
avail_mb=$(( ${avail_kb:-0} / 1024 ))
# Older kernels have no MemAvailable; report that rather than 0, which would
# read as a permanently exhausted runner.
if [ -n "$avail_kb" ]; then
avail_display="${avail_mb} MB available"
else
avail_display="available unknown"
fi
# A container usually sees the host's RAM, but may carry its own lower ceiling
# (cgroup v2 memory.max, v1 memory.limit_in_bytes). That ceiling is what the
# build is actually killed against, so it counts as the runner's size.
limit_mb=""
for f in /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory/memory.limit_in_bytes; do
[ -r "$f" ] || continue
v=$(cat "$f")
case "$v" in
''|max|*[!0-9]*) continue ;;
esac
# An unlimited v1 cgroup reports a sentinel near 2^63 bytes.
[ "$v" -lt 100000000000 ] || continue
candidate=$(( v / 1024 / 1024 ))
if [ -z "$limit_mb" ] || [ "$candidate" -lt "$limit_mb" ]; then
limit_mb="$candidate"
fi
done
echo "runner memory: ${total_mb} MB total, ${avail_display}, ${cpus} vCPU${limit_mb:+, ${limit_mb} MB cgroup limit}"
effective_mb=$total_mb
if [ -n "$limit_mb" ] && [ "$limit_mb" -lt "$effective_mb" ]; then
effective_mb=$limit_mb
fi
if [ "$effective_mb" -lt "$MIN_TOTAL_MB" ]; then
echo "::error::runner has ${effective_mb} MB of memory, a client job needs at least ${MIN_TOTAL_MB} MB (8 GB is the supported minimum; a cold production build alone peaks at about 3.8 GB, and the Cypress and Lighthouse jobs add a dev server and a browser to that)."
exit 1
fi
# Not a gate - a second job landing on the same box shows up here first.
if [ -n "$avail_kb" ] && [ "$avail_mb" -lt 5000 ]; then
echo "::warning::only ${avail_mb} MB is available on this runner; another job may be running on the same box"
fi
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
# Decide whether this pull request can change the Cypress job's outcome.
#
# The Cypress job runs the mocked estate (sas/mocks) against the client, so a
# pull request that touches none of what it exercises cannot change its
# result: a sas/ service change is not deployed there, and a documentation
# change is not read. The build job decides once and publishes the answer as a
# job output, so its gate on the cold production build and the artifact upload
# cannot disagree with the job that downloads them - re-deciding in the second
# job could see a moved base branch and ask for an artifact the first job never
# built. The Lighthouse job takes the same decision: it reads the client build,
# the mock services and the CLI pin, and nothing else.
#
# Writes client=true|false to $GITHUB_OUTPUT when a decision can be made, and
# prints the files it saw. It exits 0 WITHOUT writing anything when it cannot
# decide (no base ref, a failed fetch, a failed diff): every caller gates on
# `!= 'false'`, so an undecidable pull request runs everything. A CI change
# whose worst case is "slow" is safe; one whose worst case is "silently tests
# nothing" is not, and one whose worst case is "every pull request goes red
# because a fetch failed" is not acceptable either.
#
# .gitea/scripts/ is in the set because the spec list (cypress-specs.sh) and
# the memory gate (check-runner-memory.sh) live there: a pull request that
# changes what the suite runs, or the gate that lets it run, has to run it.
# build.yaml is in the set for the same reason.
#
# sas/package.json is in the set because the job installs the CLI from the
# version it pins (`CLI_VERSION=$(node -p "...dependencies['@sasjs/cli']")`
# in "Deploy mocked services"): bumping that pin changes the toolchain the
# suite runs, even though no file under sas/mocks/ changes. The sas lockfile
# is not in the set - the job installs that CLI globally by version string,
# so the lockfile cannot change what it resolves.
#
# sas/mocks/test/ is dropped from the list before the match. Those files are
# node:test harnesses that read the payload files; the job deploys
# sas/mocks/sasjs/ and nothing else, so a pull request that changes only the
# tests cannot change what the browser is served. Any other file under
# sas/mocks/ still counts, including a change that touches the tests alongside
# a service.
#
# Usage: BASE_REF=<base branch> bash .gitea/scripts/cypress-changes.sh
set -uo pipefail
# The paths the Cypress job reads, and nothing else. The tests in
# tests/cypress-changes.test.sh exist because this list is the whole gate: a
# dropped alternative would silently stop the suite running on a class of
# change, and the only symptom would be a green job that finished sooner.
PATHS='client/|sas/mocks/|sas/package\.json|\.gitea/scripts/|package-lock\.json|\.gitea/workflows/build\.yaml'
# --match < file-list reads newline-separated paths on stdin and prints
# true/false, so the matcher can be exercised with fixed lists and no
# repository. It is what the test above drives.
# A path list on stdin, minus the mock tree's own tests, which the job does
# not deploy.
served_changes() { grep -v '^sas/mocks/test/' || true; }
if [ "${1:-}" = "--match" ]; then
if served_changes | grep -qE "^(${PATHS})"; then echo true; else echo false; fi
exit 0
fi
BASE="${BASE_REF:-}"
if [ -z "$BASE" ]; then
echo "no base ref (not a pull request) - running the suite"
exit 0
fi
if ! git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE}:refs/remotes/origin/${BASE}"; then
echo "::warning::could not fetch ${BASE} - running the suite rather than deciding"
exit 0
fi
if ! CHANGED="$(git diff --name-only "refs/remotes/origin/${BASE}" HEAD)"; then
echo "::warning::could not diff against ${BASE} - running the suite rather than deciding"
exit 0
fi
if served_changes <<<"$CHANGED" | grep -qE "^(${PATHS})"; then
echo "client=true" >>"${GITHUB_OUTPUT:-/dev/null}"
echo "the Cypress job's inputs changed:"
else
echo "client=false" >>"${GITHUB_OUTPUT:-/dev/null}"
echo "::notice::Cypress suite skipped - nothing under client/, sas/mocks/ (outside sas/mocks/test/) or .gitea/scripts/ changed"
echo "Skipping the Cypress suite - nothing it exercises changed:"
fi
printf '%s\n' "$CHANGED"
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# The Cypress specs CI runs, and the order they run in.
#
# ORDER MATTERS. These specs share one mock estate that is seeded once per job,
# so several of them depend on the state they find:
#
# - csv-limited asserts the free-tier "Locked Feature" modal, which an earlier
# spec can remove by planting a valid licence. It has always run first.
# - hook-programs asserts that an edit raises an approval row against its
# target table; it runs early, before other specs have added rows to the
# approval tables.
# - metanav only reads the metadata tree, and applies a licence key so it can
# run on its own; it goes last so that key cannot change another spec.
# - va-ddc-host asserts nothing about licensing, so it works on either side of
# metanav; it sits before it, which leaves metanav last.
#
# Specs added later are appended rather than inserted, so the sequence that was
# known green keeps its shape. metanav is the exception - it has to stay last for
# the key it plants, so a new spec goes in front of it.
#
# download was held out while its selectors were stale - see
# https://git.datacontroller.io/dc/dc/issues/398. Its first case uploads a fixture
# to mpe_x_test and submits it, so it creates the approval it downloads instead of
# depending on what earlier specs leave behind. That approval stays: the CI job
# deletes the appLoc and re-runs makedata before the specs, so nothing accumulates
# between runs, but a hand-run against a reused estate leaves one pending row per
# run. It is appended because a spec added later goes on the end rather than
# reordering the sequence that was known green.
set -euo pipefail
SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va deploy-folder-picker download va-ddc-host metanav"
# Specs that exist but are held out of CI, each with the reason. Listed here so
# the coverage check below can tell a deliberate exclusion from a forgotten file.
# Nothing is held out at present.
EXCLUDED=""
case "${1:-}" in
all)
# comma separated, the form `cypress run --spec` takes
for name in $SPECS; do printf 'cypress/e2e/%s.cy.ts,' "$name"; done | sed 's/,$//'
;;
coverage)
on_disk=$(cd "$2/client" && ls cypress/e2e/*.cy.ts | xargs -n1 basename | sed 's/\.cy\.ts$//' | sort)
listed=$(printf '%s\n' $SPECS $EXCLUDED | sort)
missing=$(comm -23 <(echo "$on_disk") <(echo "$listed"))
if [ -n "$missing" ]; then
echo "Specs that are neither run nor explicitly excluded:"
echo "$missing"
echo "Add them to SPECS (or EXCLUDED, with a reason) in .gitea/scripts/cypress-specs.sh"
exit 1
fi
echo "All $(echo "$on_disk" | wc -l) specs are accounted for: $(echo $SPECS | wc -w) run, $(echo $EXCLUDED | wc -w) excluded."
;;
*)
echo "usage: $0 {all|coverage <repo-root>}" >&2
exit 2
;;
esac
-178
View File
@@ -1,178 +0,0 @@
#!/usr/bin/env node
// Publish the Lighthouse scores as ONE pull-request comment, updated in place.
//
// Why a script rather than a step of shell: the reports are JSON, the comment
// has to be found again on the next run and patched instead of posted (a PR
// that gets a fresh comment every push is noise), and neither jq nor a
// token-safe way to build the body is guaranteed in the job image. Node is,
// because the audit already needs it for lhci.
//
// Contract with the workflow:
// GITEA_TOKEN / GITHUB_TOKEN token to comment with (either name)
// GITHUB_API_URL e.g. https://git.datacontroller.io/api/v1
// GITHUB_REPOSITORY owner/repo
// PR_NUMBER the pull request to comment on
// RUN_URL link to the run, so the table has provenance
// REPORTS_DIR default client/lighthouse-reports
//
// It never fails the job: the audit's verdict is the audit step's business, and
// a comment that could not be posted must not turn a green audit red. It exits
// non-zero ONLY when it cannot find the reports at all, which means the
// workflow is wired to the wrong directory and someone should know.
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const MARKER = '<!-- lighthouse-report -->'
const dir = process.env.REPORTS_DIR || 'client/lighthouse-reports'
const api = (process.env.GITHUB_API_URL || '').replace(/\/$/, '')
const repo = process.env.GITHUB_REPOSITORY || ''
const pr = process.env.PR_NUMBER || ''
const token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN || ''
const runUrl = process.env.RUN_URL || ''
const label = (url) => {
const short = String(url)
.replace(/^https?:\/\/[^/]+\/AppStream\/clickme\//, '')
.replace(/^https?:\/\/[^/]+\//, '/')
// A navigation that ended on the app root strips to nothing; the audit did
// visit something, so say what rather than printing an empty cell.
return short === '' ? '/' : short
}
const fmt = (score) => (typeof score === 'number' ? score.toFixed(2) : '-')
function loadReports() {
if (!existsSync(dir)) return []
const files = readdirSync(dir).filter((f) => f.endsWith('.json'))
// manifest.json is the filesystem target's index: url -> jsonPath. Prefer it,
// because a report's own finalUrl may have lost the fragment the route is
// identified by.
const manifestPath = join(dir, 'manifest.json')
const out = []
const manifestFiles = []
if (existsSync(manifestPath)) {
try {
const entries = JSON.parse(readFileSync(manifestPath, 'utf8'))
for (const e of entries) {
const p = e.jsonPath || e.jsonPathAbsolute
if (!p) continue
const base = String(p).split('/').pop()
const abs = join(dir, base)
if (!existsSync(abs)) continue
manifestFiles.push(base)
out.push({ url: e.url, report: JSON.parse(readFileSync(abs, 'utf8')) })
}
} catch (e) {
console.error(`lighthouse-report: could not read manifest.json (${e.message}); falling back to the json files`)
}
}
// Second pass for anything the manifest did not account for. Deduplicated by
// FILE, not by finalUrl: the entry the manifest lists with a fragment can be
// the same report whose own finalUrl lost it, and matching on the URL then
// printed that report twice - once under its route and once under '/'.
const used = new Set(manifestFiles)
for (const f of files) {
if (f === 'manifest.json' || used.has(f)) continue
let report
try {
report = JSON.parse(readFileSync(join(dir, f), 'utf8'))
} catch {
continue
}
if (!report || !report.categories || !report.finalUrl) continue
out.push({ url: report.finalUrl, report })
}
return out
}
function render(reports) {
const rows = reports
.map((r) => {
const c = r.report.categories || {}
return `| \`${label(r.url)}\` | ${fmt(c.performance?.score)} | ${fmt(c.accessibility?.score)} | ${fmt(c['best-practices']?.score)} | ${fmt(c.seo?.score)} |`
})
.join('\n')
const a11y = reports.map((r) => r.report.categories?.accessibility?.score).filter((s) => typeof s === 'number')
const worst = a11y.length ? Math.min(...a11y) : null
return [
MARKER,
'### Lighthouse',
'',
'| Route | Performance | Accessibility | Best practices | SEO |',
'|---|---|---|---|---|',
rows,
'',
worst === null
? 'No accessibility score in the reports.'
: `Lowest accessibility score: **${worst.toFixed(2)}** (the gate is a median of 0.85 while issue 497 is open).`,
runUrl ? `\nRun: ${runUrl}` : '',
].join('\n')
}
async function call(method, path, body) {
const res = await fetch(api + path, {
method,
headers: {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${(await res.text()).slice(0, 200)}`)
return res.status === 204 ? null : res.json()
}
/**
* The report comment this script owns, or null.
*
* The comment list is paged: a pull request with a long review history runs past
* the first page, and looking only there turned "updated in place" into a fresh
* comment on every push - the noise this script exists to prevent. The page
* limit is a bound on the walk, not an expectation.
*/
async function findComment(pr, marker) {
const perPage = 50
const maxPages = 10
for (let page = 1; page <= maxPages; page++) {
const comments = await call(
'GET',
`/repos/${repo}/issues/${pr}/comments?limit=${perPage}&page=${page}`
)
const mine = (comments || []).find(
(c) => typeof c.body === 'string' && c.body.includes(marker)
)
if (mine) return mine
if (!comments || comments.length < perPage) return null
}
return null
}
async function main() {
const reports = loadReports()
if (!reports.length) {
console.error(`lighthouse-report: no reports under ${dir}`)
process.exit(1)
}
const body = render(reports)
console.log(body)
if (!api || !repo || !pr || !token) {
console.error('lighthouse-report: missing GITHUB_API_URL/GITHUB_REPOSITORY/PR_NUMBER/token - not posting')
return
}
const mine = await findComment(pr, MARKER)
if (mine) {
await call('PATCH', `/repos/${repo}/issues/comments/${mine.id}`, { body })
console.log(`lighthouse-report: updated comment ${mine.id}`)
} else {
const created = await call('POST', `/repos/${repo}/issues/${pr}/comments`, { body })
console.log(`lighthouse-report: created comment ${created?.id}`)
}
}
main().catch((e) => {
// A comment is not a gate: log it, leave the job's own verdict alone.
console.error(`lighthouse-report: ${e.message}`)
})
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# The path list in cypress-changes.sh is the whole gate: drop an alternative
# and the Cypress suite silently stops running for a class of change, with a
# green job that just finished sooner. These cases pin the list to what the
# suite actually reads, and run in the build job on every pull request.
#
# Run by hand: bash .gitea/scripts/tests/cypress-changes.test.sh
set -uo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
matcher="$here/../cypress-changes.sh"
fails=0
check() { # <expected> <path>...
local want="$1" got
shift
got=$(printf '%s\n' "$@" | bash "$matcher" --match)
if [ "$got" != "$want" ]; then
echo "FAIL: expected $want for: $*"
echo " got: $got"
fails=$((fails + 1))
fi
}
# Everything the Cypress job reads: the client it drives, the mock estate it
# deploys, the CLI pin it installs, the scripts that decide what it runs, and
# the dependency/workflow files that change the first two.
check true client/src/app/app.component.ts
check true sas/mocks/sasjs/services/admin/makedata.js
check true sas/mocks/sasjs/services/admin/createdemodata.js
check true sas/package.json
check true .gitea/scripts/cypress-specs.sh
check true package-lock.json
check true .gitea/workflows/build.yaml
# Not inputs: a sas/ service the mock estate does not deploy, documentation,
# another workflow (only build.yaml decides what these jobs run), an unrelated
# directory whose name starts with "client", and the sas lockfile (the job
# installs the CLI by version string, so the lockfile cannot change what it
# resolves).
check false sas/macros/mp_abort.sas
check false AGENTS.md
check false docs/readme.md
check false .gitea/workflows/release.yaml
check false clientx/foo.ts
check false sas/package-lock.json
# The mock tree's own tests are not deployed, so a pull request that changes
# only them cannot change what the browser is served. A change that touches
# them next to a service still counts.
check false sas/mocks/test/sas9-separators.test.js
check false sas/mocks/test/sasjs-webout.test.js
check true sas/mocks/test/sas9-separators.test.js sas/mocks/sasjs/services/x.js
check true sas/mocks/sas9/sasjs/services/editors/getdata.js
if [ "$fails" -ne 0 ]; then
echo "cypress-changes matcher: $fails case(s) failed"
exit 1
fi
echo "cypress-changes matcher: all cases passed"
+45 -620
View File
@@ -1,79 +1,16 @@
name: Build
run-name: Lint, unit tests, Cypress suite and Lighthouse on Pull Request
run-name: Running Lint Check and Licence checker on Pull Request
on: [pull_request]
# One run per pull request: a newer push supersedes the one in flight for the
# older head. Both runs test the same thing over the same twenty-six minute
# suite, the older head can no longer be what gets merged, and this workflow
# shares three runner boxes with every other pull request in the repo - so
# leaving the old run queued only delays the head that matters. The group is the
# base build only: the release workflow is never cancelled by something it does
# not supersede.
concurrency:
group: build-pr-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
# the server the mocked services and app tests run against - bump deliberately.
# 3.12.0 is the floor: /ddc-host carries `timing=early` from there, which
# client/cypress/e2e/va-ddc-host.cy.ts needs. 3.12.2 also carries the
# packaged-executable fix for the same page, so the version the tests run
# against is the one that ships.
#
# 3.14.0 ships @sasjs/lint 5.0.0 (the studio reports OS commands) and is the
# release the estate runs. It still declares @sasjs/utils ^4.7.0, so the
# envelope *formats* (SYSCC as text, DATE7, TIME5, E8601DT26.6 from utils 4.8)
# are only guaranteed once the next server release raises that floor - a fresh
# install resolves 4.8.0 today, a cached tree need not. Bump again when it lands.
#
# 3.13.0 is the one that matters for licensing: @sasjs/utils 4.7 reports
# SYSSITE, which %webout takes from &syssite. Before it the prelude wrote an
# empty string, so the client - which routes expired and wrong-organisation
# licence keys on that value - never reached those pages on the mock estate.
#
# 3.12.3 is on top of that for a different reason: it is the first release whose
# package.json declares @sasjs/utils ^4.6.0, which is what gives webout() its
# options argument. getdata, viewdata and getcolvals pass { showmeta }, and a
# 4.5 install drops it without a word - so the pin is what makes the metadata
# members guaranteed here rather than whatever a fresh install happens to
# resolve. requireWeboutOptions in sasjs/mocks/sasjs/services/dcMockUtils.js
# refuses a runtime without it.
SASJS_SERVER_VERSION: '3.14.0'
NODE_VERSION: '24.15.0'
jobs:
Build-and-ng-test:
runs-on: ubuntu-latest
outputs:
# The Cypress job consumes this instead of deciding again: the base
# branch can move between the two jobs, and a second answer could say
# "run" after this job had already skipped the build and the upload,
# which would fail there as a missing artifact. Empty (the decision
# could not be made) means the Cypress job runs everything.
client: ${{ steps.changes.outputs.client }}
steps:
- uses: actions/checkout@v4
- name: Check runner size
# An undersized runner dies inside `ng build` (exitcode 137) or as a
# Karma disconnect twenty minutes in. Fail here instead, in seconds,
# with the measured numbers in the log - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- name: Detect the Cypress job's inputs
id: changes
# Decides whether the Cypress job will run at all, so the production
# build and its artifact upload can be skipped when nothing the suite
# exercises has changed. Same script the Cypress job itself calls, so
# the two jobs cannot disagree about which paths matter.
env:
BASE_REF: ${{ github.base_ref }}
run: bash .gitea/scripts/cypress-changes.sh
- name: Test the changed-path matcher
# The path list is the whole gate: a dropped alternative silently stops
# the suite running for a class of change, and the only symptom is a
# green job that finished sooner. The cases pin the list to what the
# suite reads.
run: bash .gitea/scripts/tests/cypress-changes.test.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
@@ -85,43 +22,15 @@ jobs:
# credential must fail the build, not just a commit.
# Uses the same @nogoo9/gitleaks binary the pre-commit hook pins - the
# gitleaks-action requires a license key for organization repos.
# Runs before the registry config is written, so the scan cannot see
# the token this job injects.
# 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.
#
# The cache steps below deliberately run AFTER this one: the Angular
# cache holds webpack/babel JSON full of `{ token: ... }` fragments,
# and a restored copy in the tree is scanned as a pile of generic API
# keys (it failed the gate once, on run 3174).
run: |
npm ci
node_modules/.bin/gitleaks detect --no-git --source . --redact --no-banner -v
- name: Restore the npm download cache
# The runner's embedded cache server is reachable from job containers
# (they share a network with the runner), so these entries can actually
# come back. The key is the client lockfile: a dependency change
# invalidates it, and the prefix restore-keys keep the previous entries
# usable while the new ones are fetched.
#
# act_runner scopes cache entries to the REPOSITORY, not the ref (the
# handler stamps each entry with its repo and checks it on read), so
# the base branch is part of the key: a pull request into release/7.22
# cannot seed the entry a pull request into main restores. Entries
# still live on the runner that saved them - each act_runner runs its
# own cache server - so a box warms its cache from its own first run.
#
# An unreachable cache server degrades both the restore and the save to
# a warning - the action never fails the job - so a box on an older
# runner config costs a timeout, not a red build.
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-client-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
npm-client-${{ github.base_ref }}-
- name: Install Google Chrome
run: |
apt-get update
@@ -129,172 +38,48 @@ jobs:
apt install -y ./google-chrome*.deb
- name: Write .npmrc file
run: |
# the registry secret stays outside the repo: client/.npmrc is
# tracked now, and one git add -A would commit a live token
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
run: echo "$NPMRC" >> client/.npmrc
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | \
echo "${{ secrets.SHEET_PWD }}" | \
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc); the client's own
# generator runs explicitly, because the app imports the file it makes
npm ci --ignore-scripts
npm run postinstall
# cypress's binary is installed in the Cypress job, where it is
# actually used; nothing in this job starts it, so downloading and
# unzipping it here only cost time (11s, measured).
- name: Restore the Angular build cache
# `ng build` and both `ng test` runs use this directory, so a restored
# entry speeds up the production build and the Karma suites alike.
#
# This step MUST stay below "Install dependencies". postinstall runs
# scripts/strip-clr-base64-fonts.mjs, which edits Clarity's CSS inside
# node_modules and then deletes client/.angular/cache, because the
# builder treats node_modules as immutable and would otherwise serve
# styles built from the unstripped file (measured: a marker @font-face
# injected into clr-ui.min.css is absent from a warm build's CSS and
# present in a cold one). Every run strips before it builds, so every
# entry in this cache was produced from the stripped file - restoring
# after the clear is what keeps that true.
#
# Exact key only, no restore-keys: the builder cannot see a change
# inside node_modules, so an entry compiled against a different
# Clarity must not be restored. A lockfile bump misses and rebuilds,
# which is the correct outcome.
#
# Angular keeps the cache in memory when CI is set, so client/
# angular.json sets cli.cache.environment to "all" - without it this
# step has nothing to save ("Path Validation Error: Path(s) specified
# in the action for caching do(es) not exist").
#
# Saving is skipped for a key that already exists, so this refreshes
# when client/package-lock.json changes; bump the v1- prefix to force
# a reset by hand. The base ref keeps a pull request into main from
# seeding what a pull request into release/7.22 restores.
uses: actions/cache@v4
with:
path: client/.angular/cache
key: angular-v1-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
npm ci
- name: Check audit
# Root and sas fail on moderate and above (npm's default), client on
# low and above - the flags below are what makes that true, so they have
# to stay in step with this comment.
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
run: |
npm audit --omit=dev
cd ./sas
npm audit --omit=dev
cd ../client
npm audit --omit=dev --audit-level=low
- name: Check archiver directory walk
# the readdir-glob minimatch override in client/package.json is a major
# bump; archiver's directory() is the only runtime caller of that path,
# so exercise it rather than trusting that the install succeeded
run: |
cd client
npm run check:archiver
npm audit --omit=dev
- name: Lint check
run: npm run lint:check
- name: SAS lint check
# sas/.sasjslint promotes each rule to "error" as its findings are
# cleared, and only errors change the exit code - so this gate fails on
# a regression in a cleared rule and passes while other rules still
# carry warnings.
run: |
cd sas
npm ci --ignore-scripts
npm run lint:sas
- name: Licence checker
run: |
cd client
npm run license-checker
- name: Mock estate service tests
# The deploy screen's questions (ACTION=DIRLIST / WRITECHECK) are
# answered by the mock makedata service on the browser-hosted sites.
# These run that service against a temporary estate, so a browse that
# seeds the site and deletes the program fails here instead of on the
# staging site, where it only shows as a wizard that cannot be used.
run: node --test sas/mocks/test/*.test.js
- name: Angular Tests
run: |
cd client
npm run test:headless
- name: Angular Tests in other timezones
# The VA filter clauses are built from local calendar dates, so a
# UTC-only run cannot see a date that slips a day west of UTC or east of
# it. Only the VA specs carry that property, so both scripts run that
# subset rather than the whole suite a second and third time.
run: |
cd client
npm run test:tz-west
npm run test:tz-east
- name: Every Cypress spec is in the CI list
# The spec list used to be spelled out inline in --spec, and five files
# quietly fell out of it - among them the VA embed suite. Fails when a
# spec on disk is not listed.
run: bash .gitea/scripts/cypress-specs.sh coverage .
- name: Production Build
# The build exists to give the Cypress job the client/dist it streams
# as the mock estate's frontend. When nothing that job exercises has
# changed, that job skips and nobody downloads the artifact, so the
# cold build (109s, measured on run 3132) would be spent on no one.
if: steps.changes.outputs.client != 'false'
run: |
cd client
npm run build
- name: Save the production build for the Cypress job
# Gated with the build it packages: when the Cypress job skips, this
# artifact has no consumer.
if: steps.changes.outputs.client != 'false'
# The Cypress job needs client/dist: the server-ci target streams it as
# the 'clickme' web app (see CONTRIBUTING.md). Handing it the build this
# job has already made saves a second cold `npm run build` (1.6 minutes,
# measured over thirteen runs) and guarantees both jobs test the same
# bytes.
#
# if-no-files-found: error, because an empty artifact is worse than a
# failed upload: the Cypress job would download nothing, deploy a service
# pack with no frontend, and the symptom would reappear there as the
# "webSourcePath ... doesn't exist" line this artefact exists to remove.
#
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all - the download in
# Build-and-test-development is @v3 for the same reason.
uses: actions/upload-artifact@v3
with:
name: client-dist
path: client/dist
if-no-files-found: error
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
Build-and-test-development:
runs-on: ubuntu-latest
needs: Build-and-ng-test
@@ -306,57 +91,16 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Take the changed-path decision from the build job
id: changes
# The build job made this decision before it built (or skipped) the
# client-dist this job downloads, and published it as a job output.
# Deciding again here could disagree if the base branch moved in
# between, and the disagreement would surface as a missing artifact.
# An empty value means that job could not decide, so the suite runs.
env:
CLIENT: ${{ needs['Build-and-ng-test'].outputs.client }}
run: |
case "${CLIENT:-}" in
true) echo "client=true" >> "$GITHUB_OUTPUT" ;;
false) echo "client=false" >> "$GITHUB_OUTPUT" ;;
*) echo "the build job published no decision - running the suite" ;;
esac
- name: Cypress suite not run - no client changes
# A named step rather than a run of greyed-out ones, so the job summary
# says why it took a minute: this job reports success having tested
# nothing, which is the intended behaviour for a pull request that
# changes no client code. If this job is a required check, "green" for
# such a pull request means "not applicable", and this step is the
# evidence for it.
if: steps.changes.outputs.client == 'false'
run: |
echo "No client, sas/mocks, sas/package.json or .gitea/scripts change in this pull request - the Cypress suite was skipped by design."
echo "The build job's 'Detect the Cypress job's inputs' step lists the files it saw, and warns instead if it could not decide (in which case the suite runs)."
- name: Check runner size
if: steps.changes.outputs.client != 'false'
# Each Cypress job compiles the frontend and runs a browser on top; a
# runner too small for that fails as a flaky-looking disconnect - see
# the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
if: steps.changes.outputs.client != 'false'
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
if: steps.changes.outputs.client != 'false'
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install system dependencies
if: steps.changes.outputs.client != 'false'
run: |
apt-get update
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
@@ -367,99 +111,47 @@ jobs:
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
- name: Write cypress credentials
if: steps.changes.outputs.client != 'false'
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
shell: bash
env:
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
- name: Restore the npm download cache
if: steps.changes.outputs.client != 'false'
# The build job saves this key with the same lockfile, so npm ci here
# should not download a tarball that job already fetched.
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-client-${{ github.base_ref }}-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
npm-client-${{ github.base_ref }}-
- name: Restore the Cypress binary
if: steps.changes.outputs.client != 'false'
# npx cypress install fetches a ~50MB binary per run without this; the
# key follows the lockfile, and a stale hit is re-checked by the installer.
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-binary-${{ hashFiles('client/package-lock.json') }}
restore-keys: |
cypress-binary-
- name: Install dependencies
if: steps.changes.outputs.client != 'false'
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc); the client's own
# generator runs explicitly, because the app imports the file it makes
npm ci --ignore-scripts
npm run postinstall
# cypress downloads its binary in a dependency install hook, which the
# policy skips, so it is installed explicitly here
npx cypress install
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
- name: Fetch the production build
if: steps.changes.outputs.client != 'false'
- 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. It is the artefact Build-and-ng-test published, so this job
# does not compile the frontend a second time.
uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Cache the npm downloads
if: steps.changes.outputs.client != 'false'
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
# "webSourcePath: .../client/dist ... doesn't exist" and streams no frontend.
run: |
cd client
npm run build
- name: Setup and start SASjs server
if: steps.changes.outputs.client != 'false'
run: |
npm i -g pm2@7.1.2
npm i -g pm2
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
echo HTTP_PORT=5000 >> .env
# /ddc-host is a testing tool, so the server answers 404 for it unless
# this is set. va-ddc-host.cy.ts drives the app through that page with
# timing=early.
echo DDC_HOST_ENABLED=true >> .env
# The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
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}"
@@ -469,57 +161,32 @@ jobs:
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
pm2 logs api-linux --lines 100 --nostream
exit 1
- name: Deploy mocked services
if: steps.changes.outputs.client != 'false'
run: |
cd ./sas/mocks/sasjs
# The CLI comes from the version sas/package.json pins, as docs.yaml and
# release-assets.yaml already do: an unpinned global install runs
# whatever release is current, unreviewed.
CLI_VERSION=$(node -p "require('../../package.json').dependencies['@sasjs/cli']")
npm install -g "@sasjs/cli@${CLI_VERSION}"
npm install -g replace-in-files-cli@4.0.0
npm install -g @sasjs/cli
npm install -g replace-in-files-cli
# Remove any previous deployment (drive folder delete API) so the
# deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
sasjs cbd -t server-ci
# Seed the DC database (mock data files on the drive). The payload
# asks for OUTFMT=JSON, so the CLI parses the response and a failed
# seed fails the job - createdemodata runs next and would only report
# a missing control library.
# Seed the DC database (mock data files on the drive).
# makedata replies with HTML (it is normally called as a URL redirect),
# which the CLI reports as "invalid Json string" - ignore that.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
# Then the Cypress fixtures: TESTDATA, the search/wide viewers and the
# multi-load library. makedata builds only what a real deployment
# builds, so the specs need this second service.
sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
- name: Prepare and run frontend and cypress
if: steps.changes.outputs.client != 'false'
# One job, one SASjs server, one ng serve, one Chrome: four of each on one
# runner starved the app under test and every failure in that run was a
# timeout, not an assertion.
timeout-minutes: 50
env:
CYPRESS_USERNAME: ${{ secrets.CYPRESS_USERNAME_SASJS }}
CYPRESS_PWD: ${{ secrets.CYPRESS_PWD_SASJS }}
run: |
cd ./client
# The spec list lives in one place, and an empty list is a broken
# workflow rather than a green run with no tests.
SPECS="$(bash ../.gitea/scripts/cypress-specs.sh all)"
if [ -z "$SPECS" ]; then
echo "the spec list resolved to nothing"
exit 1
fi
echo "specs: $SPECS"
mv ./cypress.env.example.json ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'"$CYPRESS_USERNAME"'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'"$CYPRESS_PWD"'" ' ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'${{ secrets.CYPRESS_USERNAME_SASJS }}'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
cat ./cypress.env.json
npm run postinstall
# Prepare index.html to SASJS local
@@ -529,261 +196,19 @@ jobs:
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
# timeout 2700: SIGTERM after 45 min so Cypress can flush screenshots
# timeout 2700: SIGTERM after 45 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
#
# video=false, screenshotOnRunFailure=true: recording twenty-four
# specs costs encode time on every run, and the recording is only ever
# opened after a failure. A screenshot of the failing step carries the
# same information for a fraction of the cost - and cypress.config.ts
# sets screenshotOnRunFailure false by default, so both are passed
# here, as CLI overrides, leaving local runs (and the demo recorder,
# which sets RECORD_WINDOW_SIZE) on video.
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 2700 npx cypress run --browser chrome --config video=false,screenshotOnRunFailure=true --spec "$SPECS"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 2700 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,cypress/e2e/derived-variables.cy.ts,cypress/e2e/startupservice-launcher.cy.ts"
- name: Zip Cypress failure evidence
# Only on a failure: a green run has nothing to look at.
if: failure() && steps.changes.outputs.client != 'false'
- name: Zip Cypress videos
if: always()
run: |
mkdir -p ./client/cypress/screenshots ./client/cypress/videos
zip -r cypress-artifacts.zip ./client/cypress/screenshots ./client/cypress/videos
mkdir -p ./client/cypress/videos
zip -r cypress-videos ./client/cypress/videos
- name: Add cypress failure evidence as an artifact
if: failure() && steps.changes.outputs.client != 'false'
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all.
- name: Add cypress videos artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-artifacts.zip
path: cypress-artifacts.zip
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
# The Lighthouse audit, which used to be its own workflow. It lives here so it
# can reuse the production build the first job already made: an artifact
# belongs to the run that produced it and `needs:` cannot cross workflows, so
# the separate workflow had to compile the frontend a second time (92-104s
# measured) and start an ng serve it never audited - every URL in
# client/lighthouserc.js is on :5000, which the SASjs server feeds from
# client/dist.
#
# It takes the changed-path decision from the build job instead of deciding
# again, for the reason given there: the same inputs gate both, and two
# answers could disagree about whether the artifact exists.
lighthouse:
runs-on: ubuntu-latest
needs: Build-and-ng-test
# The job comments the scores back onto the pull request, so it needs to
# write there; nothing else is elevated.
permissions:
contents: read
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
- name: Take the changed-path decision from the build job
id: changes
env:
CLIENT: ${{ needs['Build-and-ng-test'].outputs.client }}
run: |
case "${CLIENT:-}" in
true) echo "client=true" >> "$GITHUB_OUTPUT" ;;
false) echo "client=false" >> "$GITHUB_OUTPUT" ;;
*) echo "the build job published no decision - running the audit" ;;
esac
- name: Lighthouse audit not run - no client changes
# A named step rather than a run of greyed-out ones, so the job summary
# says why it took a second: this job reports success having audited
# nothing, which is the intended behaviour for a pull request that
# changes none of its inputs.
if: steps.changes.outputs.client == 'false'
run: |
echo "No client, sas/mocks, sas/package.json or .gitea/scripts change in this pull request - the audit was skipped by design."
echo "The build job's 'Detect the Cypress job's inputs' step lists the files it saw, and warns instead if it could not decide (in which case the audit runs)."
- name: Check runner size
if: steps.changes.outputs.client != 'false'
# This job runs a SASjs server, Chrome and the full mock estate; an
# undersized runner dies inside one of them with exitcode 137. Fail here
# in seconds instead - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
if: steps.changes.outputs.client != 'false'
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
if: steps.changes.outputs.client != 'false'
run: |
# the registry secret arrives through the environment, not interpolated
# into this script
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install Google Chrome
if: steps.changes.outputs.client != 'false'
# lhci launches Chrome itself; --no-sandbox comes from
# client/lighthouserc.js, which is what makes it work as root.
run: |
apt-get update
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt install -y ./google-chrome*.deb
- name: Install dependencies
if: steps.changes.outputs.client != 'false'
# Only the client's node_modules is needed here - lhci comes from it and
# nothing is compiled in this job, so the source generator (postinstall)
# and its node_modules edits are not run.
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | \
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
# dependency hooks stay off (client/.npmrc), and nothing here needs
# what they would generate
npm ci --ignore-scripts
- name: Fetch the production build
if: steps.changes.outputs.client != 'false'
# Every audited URL is on the copy the SASjs server streams from
# client/dist, so this audits exactly the bytes the build job produced
# instead of compiling a second, possibly different, copy of them.
uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Cache the npm downloads
if: steps.changes.outputs.client != 'false'
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Install global packages
if: steps.changes.outputs.client != 'false'
run: |
# pinned, so what runs here is a release this repo chose: the CLI at
# the version sas/package.json pins, pm2 at the version named here
CLI_VERSION=$(node -p "require('./sas/package.json').dependencies['@sasjs/cli']")
npm i -g pm2@7.1.2
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Setup and start SASjs server
if: steps.changes.outputs.client != 'false'
run: |
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
# No CORS: the audited app is served by this same server (:5000), so
# every call it makes is same-origin. The Cypress job needs CORS
# because its browser is on ng serve's :4200; this job has no dev
# server, and the server refuses to start with CORS=enable and no
# WHITELIST.
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account (a
# security change in the recent releases). This job runs as root in a
# throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
exit 0
fi
sleep 2
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
exit 1
- name: Deploy JS mocked services and frontend to the SASjs server
if: steps.changes.outputs.client != 'false'
# `cd sas/mocks/sasjs` is explicit: sas/mocks holds no sasjsconfig.json,
# and running from there made the CLI search for the project while its
# `npm ci` installed whatever package.json the prefix walk found. The
# server-ci target deploys to /Public/app/dc, which is the appLoc the
# production build already carries, so the audited app calls the
# services this step creates with no attribute patching in between.
run: |
test -f client/dist/index.html || { echo "the artifact carries no client/dist/index.html"; ls -la client/dist; exit 1; }
cd sas/mocks/sasjs
# This server is fresh in this container, so there is no previous
# deployment to delete - cbd is the first thing to write the appLoc.
sasjs cbd -t server-ci
# Seed the DC database and the Cypress fixtures, exactly as the Cypress
# job does: most audited routes are data views (tables, view/data, the
# review lists, excel-maps, multi-load), and an unseeded estate renders
# empty or error states instead of the app users get. The payload asks
# for OUTFMT=JSON so a failed seed fails this job; createdemodata runs
# after it because it needs the control library makedata creates.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks), not
# the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
- name: Run Lighthouse CI
if: steps.changes.outputs.client != 'false'
# One run per URL: the assertions aggregate by median, so three runs
# bought a steadier median at three times the navigations (13.6 minutes
# measured). A pull request gate only needs to know the page still
# clears the thresholds it cleared before.
run: |
cd client
npx lhci autorun
- name: Publish the scores to the pull request
# One comment, rewritten in place on every push: the marker in the body is
# how the script finds the previous run's comment again, so a re-run
# replaces the old table rather than adding another one. It runs on
# failure too - a red audit is exactly when the scores are worth reading -
# and a comment that cannot be posted never fails the job, because the
# audit's own step carries the verdict.
if: always() && steps.changes.outputs.client != 'false' && github.event_name == 'pull_request'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .gitea/scripts/lighthouse-report.mjs
- name: Lighthouse Result Artifacts
if: always() && steps.changes.outputs.client != 'false'
# v3, not v4: this runner reports "GHESNotSupportedError" for the
# artifact action's current major. No `include-hidden-files` input -
# that arrived with v4.4 and v3 only warns about it as an unexpected
# input, which reads as a misconfiguration in every run's log.
uses: actions/upload-artifact@v3
with:
name: Lighthouse results
path: client/lighthouse-reports
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
name: cypress-videos.zip
path: cypress-videos.zip
-130
View File
@@ -1,130 +0,0 @@
name: Publish code documentation
run-name: Publishing typedoc and SAS docs
# The code documentation sites are published here rather than inline in the
# release job, so that they can be republished on their own - after a secret
# rotation, or for a release whose artefact upload failed (the release job
# cannot simply be re-run: semantic-release refuses once the tag exists).
on:
workflow_dispatch:
env:
NODE_VERSION: '24.15.0'
jobs:
typedoc:
runs-on: ubuntu-latest
# One deploy per site at a time: `surfer deploy` swaps the site atomically,
# so two runs racing is not corruption, but it is a wasted build and the
# last swap wins.
concurrency:
group: surfer-webdoc
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC }}
- name: Install dependencies
run: |
cd client
# Decrypt and Install sheet
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
shell: bash
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
- name: Build typedoc
run: |
cd client
npm run compodoc:build
# Deploy tooling is pinned to an exact version: an unpinned `latest` would
# let a breaking or compromised upstream release publish straight to the
# live site. `deploy` (v7) replaces the site atomically, and it takes the
# Cloudron username plus an app password - the v6 `put --token` flow this
# replaces is gone.
#
# webdoc is a NAMED site on the app whose default site is the marketing
# site, so the name and its alias domain are both passed. Surfer picks
# the target only from `--site-name` (it travels as the Surfer-Deployment
# header); without it `deploy` replaces the app's DEFAULT site, which is
# what serves datacontroller.io - so an unnamed deploy published the
# Typedoc over the top of the marketing site instead of to webdoc.
- name: Deploy typedoc
env:
SURFER_PASSWORD: ${{ secrets.WEBDOC_DATACONTROLLER_IO }}
REF_NAME: ${{ gitea.ref_name }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
npx --yes @cloudron/surfer@7.2.0 deploy client/documentation \
--server webdoc.datacontroller.io \
--site-name webdoc \
--domain webdoc.datacontroller.io \
--username allan \
--password "$SURFER_PASSWORD" \
--message "Typedoc from ${REF_NAME} @ ${COMMIT_SHA:0:8}"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
sas-docs:
runs-on: ubuntu-latest
concurrency:
group: surfer-code-datacontroller
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: |
# `sasjs doc` shells out to doxygen
apt-get update
apt-get install -y doxygen
cd sas
npm ci
CLI_VERSION=$(node -p "require('./package.json').dependencies['@sasjs/cli']")
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Build SAS docs
run: |
cd sas
sasjs doc
# No `--site-name` here on purpose: code.datacontroller.io is its own app
# and the SAS docs are its DEFAULT site, so naming one would create a
# separate site and leave the served copy stale. Do not copy the named
# form from the typedoc job above - that app's default site is the
# marketing site, which is why it must not be the target.
- name: Deploy SAS docs
env:
SURFER_PASSWORD: ${{ secrets.WEBDOC_DATACONTROLLER_IO }}
REF_NAME: ${{ gitea.ref_name }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
npx --yes @cloudron/surfer@7.2.0 deploy sas/sasjsbuild/sasdocs \
--server code.datacontroller.io \
--username allan \
--password "$SURFER_PASSWORD" \
--message "SAS docs from ${REF_NAME} @ ${COMMIT_SHA:0:8}"
+110
View File
@@ -0,0 +1,110 @@
name: Lighthouse Checks
run-name: Running Lighthouse Performance and Accessibility Checks on Pull Request
on: [pull_request]
env:
NODE_VERSION: '24.15.0'
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Google Chrome
run: |
apt-get update
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
apt install -y ./google-chrome*.deb
- name: Install global packages
run: npm i -g pm2 @sasjs/cli wait-on
- name: Setup and start SASjs server
run: |
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
# 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
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
- name: Install npm dependencies
run: |
cd client
# Decrypt and Install sheet
echo "${{ secrets.SHEET_PWD }}" | \
gpg --batch --yes --passphrase-fd 0 \
--output ./libraries/sheet-crypto.tgz \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm ci
npm install -g replace-in-files-cli
- name: Update appLoc in index.html
run: |
cd client
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
- name: Build Frontend
run: |
cd client
npm run build
- name: Deploy JS mocked services and frontend to the local SASjs Server instance
run: |
cd sas/mocks
npm ci
sasjs cbd -t server-ci
- name: Start frontend server
run: |
cd client
npx ng serve --host 0.0.0.0 --port 4200 &
wait-on http://localhost:4200
- name: Run Lighthouse CI
run: |
cd client
npx lhci autorun
- name: Lighthouse Result Artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: Lighthouse results
path: client/lighthouse-reports
include-hidden-files: true
-225
View File
@@ -1,225 +0,0 @@
name: Release assets
run-name: Building and uploading the release assets
# Builds the downloadable artefacts for an existing release and uploads them.
#
# The release job cuts the tag before it builds, so a failure anywhere between
# the semantic-release step and the asset upload leaves a published release with
# no files - and that job cannot simply be re-run, because semantic-release
# refuses once the tag exists. This workflow rebuilds the artefacts from the tag
# and attaches them, so the release can be completed without cutting a new one.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and upload (blank = the latest release)'
required: false
default: ''
env:
NODE_VERSION: '24.15.0'
permissions:
contents: write
jobs:
assets:
runs-on: ubuntu-latest
# Two runs uploading the same asset names would race the delete-then-upload
# below.
concurrency:
group: release-assets
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check runner size
# Builds the same frontend as the release jobs; an undersized runner
# fails inside the build - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
shell: bash
env:
NPMRC: ${{ secrets.NPMRC }}
- name: Install system packages
# jq parses the release JSON below, so it is installed before the tag
# resolve.
run: |
apt-get update
apt-get install -y zip jq
- name: Resolve the release tag
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REQUESTED_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
if [ -n "${REQUESTED_TAG}" ]; then
TAG="${REQUESTED_TAG}"
# Fail here rather than after a ten-minute build if the tag is not
# cut yet or has no release.
curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${TAG}" > /dev/null
else
TAG=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest" | jq -r '.tag_name')
fi
echo "Building assets for ${TAG}"
echo "TAG=${TAG}" >> "$GITHUB_ENV"
- name: Check out the tag
run: |
set -euo pipefail
git fetch --force --tags origin "+refs/tags/${TAG}:refs/tags/${TAG}"
git checkout --force "refs/tags/${TAG}"
- name: Install the SASjs CLI the tag pins
run: |
cd sas
CLI_VERSION=$(node -p "require('./package.json').dependencies['@sasjs/cli']")
npm i -g "@sasjs/cli@${CLI_VERSION}"
- name: Frontend build
run: |
cd client
# Decrypt and Install sheet
echo "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
# webpack comes from the client lockfile, so install the version it
# already resolves rather than whatever is current at release time
WEBPACK_VERSION=$(node -p "require('./package-lock.json').packages['node_modules/webpack'].version")
npm i --no-save "webpack@${WEBPACK_VERSION}"
npm run build
shell: bash
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
- name: Build SAS9 EBI Release
run: |
cd sas
npm i
sasjs c -t sas9
rm -rf sasjsbuild/tests
sasjs b -t sas9
cp sasjsbuild/mysas9deploy.sas ./demostream_sas9.sas
# remove streamed component and rebuild SAS 9 services
rm -rf sasjsbuild/services/web9
rm sasjsbuild/services/clickme.sas
sasjs b -t sas9
cp sasjsbuild/mysas9deploy.sas ./sas9.sas
- name: Build SASjs Server Release
run: |
cd sas
cp sasjs/utils/favicon.ico ../client/dist/favicon.ico
sasjs c -t server
rm -rf sasjsbuild/tests
server_apploc="/Public/app/dc"
sed -i "s|apploc=\"[^\"]*\"|apploc=\"${server_apploc}\"|g" sasjsbuild/services/web/index.html
sasjs b -t server
cp sasjsbuild/server.json.zip ./sasjs_server.json.zip
- name: Build Viya Release
run: |
cd sas
sasjs c -t viya
rm -rf sasjsbuild/tests
sed -i -e 's/servertype="SASJS"/servertype="SASVIYA"/g' sasjsbuild/services/DC.html
sasjs b -t viya
cp sasjsbuild/viya.sas ./viya.sas
# compile Viya Full deploy (without web)
rm -rf sasjsbuild/services/web
rm sasjsbuild/services/DC.html
sasjs b -t viya
cp sasjsbuild/viya.sas ./viya_noweb.sas
cp sasjsbuild/viya.json ./viya_noweb.json
- name: Zip Frontend (including viya.json for full viya deploy)
run: |
cd sas
cp sasjsbuild/viya.json ../client/dist/viya.json
# Zip from *inside* dist so the archive holds the frontend files at its
# root rather than under a client/dist/ prefix (see #147).
cd ../client/dist
zip -r ../../frontend.zip .
- name: Upload assets to the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
ASSETS=(frontend.zip
sas/demostream_sas9.sas
sas/viya.sas
sas/sasjs_server.json.zip
sas/sas9.sas
sas/viya_noweb.sas
sas/viya_noweb.json)
: > SHA256SUMS
for f in "${ASSETS[@]}"; do
sum=$(sha256sum "$f" | cut -d' ' -f1)
printf '%s %s\n' "$sum" "${f##*/}" >> SHA256SUMS
done
cat SHA256SUMS
# Replacing the assets means removing the ones this workflow uploads,
# and a second run must not append the verification section twice. An
# asset the release carries from another source is left alone.
OWNED=$(printf '%s\n' "${ASSETS[@]##*/}" SHA256SUMS)
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.id) \(.name)"' |
while read -r asset_id asset_name; do
if printf '%s\n' "$OWNED" | grep -qxF -- "$asset_name"; then
echo "Removing previous ${asset_name}"
curl --fail-with-body -sS -X DELETE -H "$AUTH_HEADER" "$BASE/releases/${RELEASE_ID}/assets/${asset_id}"
else
echo "Leaving ${asset_name} in place: this workflow did not upload it"
fi
done
if echo "$RELEASE_BODY" | grep -q "## Verifying the download"; then
echo "Release body already carries the verification section"
else
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
NEW_BODY=$(jq -nr --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
'$body + "\n" + $verify + "\n\nFor installation instructions, please visit https://docs.datacontroller.io/"')
curl --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
"$BASE/releases/$RELEASE_ID"
fi
URL="$BASE/releases/${RELEASE_ID}/assets"
for f in "${ASSETS[@]}"; do
echo "Uploading $f ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
echo "Uploading SHA256SUMS ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
+55 -178
View File
@@ -6,9 +6,7 @@ on:
- main
env:
# the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.14.0'
NODE_VERSION: '24.15.0'
NODE_VERSION: '24.5.0'
jobs:
Build-production-and-ng-test:
@@ -18,24 +16,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# A release job that OOMs costs the tag as well as the slot; fail in
# seconds on an undersized runner instead of twenty minutes in - see
# the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install Chrome for Angular tests
run: |
@@ -50,16 +38,11 @@ jobs:
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
- name: Install dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Check audit
# Audit should fail and stop the CI on any vulnerability in root and sas, and on low+ in client
@@ -79,14 +62,8 @@ jobs:
run: |
cd client
npm run postinstall
npm run check:archiver
npm run build
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
Build-and-test-development:
runs-on: ubuntu-latest
needs: Build-production-and-ng-test
@@ -98,23 +75,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# The development build plus a browser: same undersized-runner failure
# as the review jobs, on the path that makes the tag - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install system dependencies
run: |
@@ -133,57 +101,32 @@ jobs:
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }}
- name: Install dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Build frontend for web streaming
# The server-ci target streams client/dist as the 'clickme' web app, so
# the production build has to exist before the mocked services are
# deployed. Without it `sasjs cbd` reports
# "webSourcePath: .../client/dist ... doesn't exist", streams no
# frontend and the deploy step fails.
run: |
cd client
npm run build
- name: Cache the npm downloads
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Setup and start SASjs server
run: |
npm i -g pm2@7.1.2
npm i -g pm2
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo CORS=enable >> .env
echo WHITELIST=http://localhost:4200 >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
# (a security change in the recent releases). This job runs as root
# in a throwaway container, so opt in explicitly.
echo RUN_AS=root >> .env
cat .env
pm2 start npx --name sasjs-server -- --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }}
# `pm2 start` returns before the app is listening, and the next step
# hits :5000 immediately. Wait for the server to answer, and dump its
# log if it never does.
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}"
@@ -193,48 +136,33 @@ jobs:
done
echo "SASjs server did not come up within 120s"
pm2 list
pm2 logs sasjs-server --lines 100 --nostream
pm2 logs api-linux --lines 100 --nostream
exit 1
- name: Deploy mocked services
run: |
cd ./sas/mocks/sasjs
# The CLI comes from the version sas/package.json pins, as docs.yaml and
# release-assets.yaml already do: an unpinned global install runs
# whatever release is current, unreviewed.
CLI_VERSION=$(node -p "require('../../package.json').dependencies['@sasjs/cli']")
npm install -g "@sasjs/cli@${CLI_VERSION}"
npm install -g replace-in-files-cli@4.0.0
npm install -g @sasjs/cli
npm install -g replace-in-files-cli
# Remove any previous deployment (drive folder delete API) so the
# deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
sasjs cbd -t server-ci
# Seed the DC database (mock data files on the drive). The payload
# asks for OUTFMT=JSON, so the CLI parses the response and a failed
# seed fails the job - createdemodata runs next and would only report
# a missing control library.
# Seed the DC database (mock data files on the drive).
# makedata replies with HTML (it is normally called as a URL redirect),
# which the CLI reports as "invalid Json string" - ignore that.
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs).
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
# Then the Cypress fixtures: TESTDATA, the search/wide viewers and the
# multi-load library. makedata builds only what a real deployment
# builds, so the specs need this second service.
sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
- name: Prepare and run frontend and cypress
env:
CYPRESS_USERNAME: ${{ secrets.CYPRESS_USERNAME_SASJS }}
CYPRESS_PWD: ${{ secrets.CYPRESS_PWD_SASJS }}
run: |
cd ./client
mv ./cypress.env.example.json ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'"$CYPRESS_USERNAME"'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'"$CYPRESS_PWD"'" ' ./cypress.env.json
replace-in-files --regex='"username".*' --replacement='"username":"'${{ secrets.CYPRESS_USERNAME_SASJS }}'",' ./cypress.env.json
replace-in-files --regex='"password".*' --replacement='"password":"'${{ secrets.CYPRESS_PWD_SASJS }}'" ' ./cypress.env.json
cat ./cypress.env.json
npm run postinstall
# cypress downloads its binary in a dependency install hook, which the
# client policy skips
npx cypress install
# Prepare index.html to SASJS local
replace-in-files --regex='serverUrl=".*?"' --replacement='serverUrl="http://localhost:5000"' ./src/index.html
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/dc"' ./src/index.html
@@ -242,37 +170,20 @@ jobs:
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts
# Start frontend and run cypress
#
# video=false, screenshotOnRunFailure=true: recording these eleven
# specs costs encode time on every run, and the recording is only ever
# opened after a failure. A screenshot of the failing step carries the
# same information for a fraction of the cost - and cypress.config.ts
# sets screenshotOnRunFailure false by default, so both are passed
# here, as CLI overrides, leaving local runs (and the demo recorder,
# which sets RECORD_WINDOW_SIZE) on video.
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --config video=false,screenshotOnRunFailure=true --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts"
- name: Zip Cypress failure evidence
# Only on a failure: a green run has nothing to look at.
if: failure()
- name: Zip Cypress videos
if: always()
run: |
mkdir -p ./client/cypress/screenshots ./client/cypress/videos
zip -r cypress-artifacts.zip ./client/cypress/screenshots ./client/cypress/videos
mkdir -p ./client/cypress/videos
zip -r cypress-videos ./client/cypress/videos
- name: Add cypress failure evidence as an artifact
if: failure()
# v3, not v4: this runner reports "GHESNotSupportedError:
# @actions/artifact v2.0.0+, upload-artifact@v4+ ... not supported on
# GHES", so the current major cannot upload here at all.
- name: Add cypress videos artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-artifacts.zip
path: cypress-artifacts.zip
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
name: cypress-videos.zip
path: cypress-videos.zip
release:
runs-on: ubuntu-latest
@@ -294,19 +205,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check runner size
# This job builds the frontend twice (webdoc and the release check);
# both die the same way on an undersized runner - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
echo "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "legacy-peer-deps=true" >> "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
echo "$NPMRC" > client/.npmrc
echo "legacy-peer-deps=true" >> client/.npmrc
shell: bash
env:
NPMRC: ${{ secrets.NPMRC}}
@@ -319,16 +225,11 @@ jobs:
- name: Frontend Preliminary Build
description: We want to prevent creating empty release if frontend fails
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
npm i webpack
npm run build
@@ -369,11 +270,6 @@ jobs:
semantic-release
# The tag this job cuts, for the steps that publish against it: a
# `releases/latest` lookup would pick whichever release landed last
# if two overlap.
echo "RELEASE_TAG=v$(node -p "require('./package.json').version")" >> "$GITHUB_ENV"
- name: Frontend Build
description: Must be created AFTER the release as the version (git tag) is used in the interface
run: |
@@ -434,6 +330,19 @@ jobs:
cd ../client/dist
zip -r ../../frontend.zip .
- name: Release Typedoc
run: |
cd client
npm -g install cloudron-surfer
npm run compodoc:build
surfer put --token ${{ secrets.TSDOC_TOKEN }} --server webdoc.datacontroller.io documentation/* /
- name: Release code.datacontroller.io
run: |
cd sas
sasjs doc
surfer put --token ${{ secrets.CODE_DATACONTROLLER_IO }} --server code.datacontroller.io sasjsbuild/sasdocs/* /
- name: Upload assets to release
env:
# Use the same ephemeral per-job Gitea Actions token that the
@@ -448,7 +357,7 @@ jobs:
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
RELEASE_JSON=$(curl --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/tags/${RELEASE_TAG}")
RELEASE_JSON=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
RELEASE_BODY=$(echo "$RELEASE_JSON" | jq -r '.body')
@@ -482,7 +391,7 @@ jobs:
VERIFY_SECTION=$(./.gitea/scripts/verify-section.sh)
NEW_BODY=$(jq -nr --arg body "$RELEASE_BODY" --arg verify "$VERIFY_SECTION" \
'$body + "\n" + $verify + "\n\nFor installation instructions, please visit https://docs.datacontroller.io/"')
curl --fail-with-body -sS -X PATCH \
curl -k --fail-with-body -sS -X PATCH \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg body "$NEW_BODY" '{draft:false, body:$body}')" \
@@ -492,37 +401,10 @@ jobs:
URL="$BASE/releases/$RELEASE_ID/assets"
for f in "${ASSETS[@]}"; do
echo "Uploading $f ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@$f"
done
echo "Uploading SHA256SUMS ..."
curl --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Publish the code documentation
# Typedoc (webdoc) and the SAS docs (code) are published by docs.yaml,
# not inline here. Two reasons: the v6 `surfer put --token` flow is gone
# in Surfer 7, which takes a Cloudron username plus an app password; and
# a dispatch means a stale or failed docs deploy can be republished
# without re-running this job, which semantic-release refuses once the
# tag exists.
#
# This runs AFTER the release assets are uploaded: the documentation is
# a side channel, and a docs problem must not cost a cut release its
# artefacts.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="Authorization: token $GITEA_TOKEN"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
TAG="${RELEASE_TAG}"
echo "Dispatching code documentation for ${TAG}"
curl --fail-with-body -sS -X POST \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
"$BASE/actions/workflows/docs.yaml/dispatches"
curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$URL" -F "attachment=@SHA256SUMS"
- name: Publish the demo site for this release
# The tag is created by this job's semantic-release step, so it carries
@@ -537,18 +419,13 @@ jobs:
AUTH_HEADER="Authorization: token ${GITEA_TOKEN}"
BASE="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
TAG="${RELEASE_TAG}"
TAG=$(curl -k --fail-with-body -sS -H "$AUTH_HEADER" "$BASE/releases/latest" | jq -r '.tag_name')
echo "Dispatching Sites for ${TAG}"
# the dispatch endpoint resolves a bare name as a branch; a tag needs
# the refs/tags/ prefix
curl --fail-with-body -sS -X POST \
curl -k --fail-with-body -sS -X POST \
-H "$AUTH_HEADER" \
-H 'Content-Type: application/json' \
--data "$(jq -n --arg ref "refs/tags/$TAG" '{ref: $ref}')" \
"$BASE/actions/workflows/sites.yaml/dispatches"
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
+18 -85
View File
@@ -10,8 +10,6 @@ on:
workflow_dispatch:
env:
# the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.14.0'
NODE_VERSION: '24.15.0'
APP_LOC: /Public/app/dc
SURFER_HOST: https://demo.datacontroller.io
@@ -38,35 +36,21 @@ jobs:
# The tag is what the client's build stamp describes (src/version.ts
# runs git-describe), so the demo build needs the tags in the clone.
fetch-depth: 0
- name: Check runner size
# The demo build is the same cold production build; an undersized
# runner fails inside it - see the script.
run: bash .gitea/scripts/check-runner-memory.sh
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Write .npmrc file
run: |
# the secret arrives through the environment, not interpolated into
# this script: a quote or $( in the value would otherwise break the
# step or execute
printf '%s\n' "$NPMRC" > "$RUNNER_TEMP/npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/npmrc" >> "$GITHUB_ENV"
env:
NPMRC: ${{ secrets.NPMRC}}
touch client/.npmrc
echo '${{ secrets.NPMRC}}' > client/.npmrc
- name: Install client dependencies
env:
SHEET_PWD: ${{ secrets.SHEET_PWD }}
run: |
cd client
# Decrypt and Install sheet
printf '%s\n' "$SHEET_PWD" | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
npm ci
# client install scripts are off (client/.npmrc); the generator that
# creates src/environments/version.ts runs explicitly
npm run postinstall
- name: Build frontend
# The site serves this build. `sasjs build` rewrites its index.html to
@@ -82,29 +66,18 @@ jobs:
cd sas
npm ci
- name: Cache the npm downloads
# The server is installed from npm at a pinned version, so the tarball is
# immutable and the key never needs invalidating by hand: a hit skips
# the 10.7MB download, a miss costs exactly what the zip cost before.
# continue-on-error, because a runner without cache support should still
# run the pipeline - this step only ever saves time.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-sasjs-server-${{ env.SASJS_SERVER_VERSION }}
- name: Start SASjs server
# Needed only to compile and deploy the mocked services, and - for a tag -
# to seed the drive the demo ships with (see the two steps below).
# Needed only to run makedata: the drive the site ships with is captured
# from a real server (see the snapshot step below).
run: |
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip -q linux.zip
touch .env
echo RUN_TIMES=js >> .env
echo NODE_PATH=node >> .env
echo HTTP_PORT=5000 >> .env
# The server refuses to run as root unless RUN_AS names an account
echo RUN_AS=root >> .env
nohup npx --yes @sasjs/server@${{ env.SASJS_SERVER_VERSION }} > server.log 2>&1 &
nohup ./api-linux > server.log 2>&1 &
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:5000/; then
echo "SASjs server answered on attempt ${i}"
@@ -116,29 +89,18 @@ jobs:
cat server.log
exit 1
- name: Deploy the mocked services
- name: Deploy the mocked services and seed the database
run: |
cd sas/mocks/sasjs
# Start from a clean appLoc so the site below carries this build alone,
# Start from a clean appLoc so the snapshot below is this build alone,
# not a merge with whatever a previous run left on the drive.
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=$APP_LOC"
../../node_modules/.bin/sasjs cbd -t server-ci
- name: Seed the demo drive
# A tag publishes the demo, the site a visitor can use straight away, so
# its drive is seeded here and captured for publishing. A release branch
# publishes staging, which is deliberately left unconfigured (see the
# step below) so it opens on the deploy screen.
if: ${{ startsWith(gitea.ref, 'refs/tags/') }}
run: |
cd sas/mocks/sasjs
# the payload asks for OUTFMT=JSON, so a failed seed fails this step
# rather than being published as an empty site (the drive is checked
# as well, before the snapshot is taken)
../../node_modules/.bin/sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
# the sample tables the demo site opens with are a separate service
# now, so that makedata matches what a SAS deployment builds
../../node_modules/.bin/sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
# makedata replies with HTML (it is normally called as a URL redirect),
# which the CLI reports as "invalid Json string" - that exit is
# expected. A real failure must not be published as an empty site, so
# the drive is checked before the snapshot is taken.
../../node_modules/.bin/sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
DATA_DIR="../../../sasjs_root/drive/files$APP_LOC/data"
SEEDED=$(find "$DATA_DIR" -name '*.json' 2>/dev/null | wc -l)
@@ -149,28 +111,6 @@ jobs:
exit 1
fi
- name: Check the staging drive is unconfigured
# Staging exists to show the deployment, so a visitor has to land on the
# deploy screen rather than on a control library. DC routes there while
# makedata is still on the drive, and stops once the wizard has run it
# and it has removed itself - so the drive is shipped with makedata and
# without any seeded tables. Seeding staging by accident would only be
# visible as a site that opens on the home screen, so it is refused here.
if: ${{ !startsWith(gitea.ref, 'refs/tags/') }}
run: |
ADMIN_DIR="sasjs_root/drive/files$APP_LOC/services/admin"
DATA_DIR="sasjs_root/drive/files$APP_LOC/data"
if [ -z "$(find "$ADMIN_DIR" -name 'makedata.js' 2>/dev/null | head -1)" ]; then
echo "makedata is not on the drive - staging would not offer the deploy screen"
find "$ADMIN_DIR" -maxdepth 1 2>/dev/null | head -20
exit 1
fi
if [ -n "$(find "$DATA_DIR" -name 'mpe_tables.json' 2>/dev/null | head -1)" ]; then
echo "the staging drive is seeded - staging would open on the home screen"
exit 1
fi
echo "staging ships an unconfigured drive: makedata present, no control library"
- name: Build the browser-hosted site
run: |
# The published sites ship a working licence so they are usable beyond
@@ -206,11 +146,9 @@ jobs:
mkdir -p "$LICENCE_DIR"
cp sas/mocks/mock-licence.json "$LICENCE_DIR/licence.json"
# The drive is captured and shipped as the site's starting drive: for the
# demo that is a seeded control library; for staging it is the services
# and the licence only, with makedata still on it so the app opens on
# the deploy screen. services/web9 is the streamed frontend, which is
# already the site itself.
# DC's data files exist only after makedata has run, so the drive is
# captured first and shipped as the site's starting drive. services/web9
# is the streamed frontend, which is already the site itself.
node sas/mocks/snapshot.js sasjs_root/drive "$APP_LOC" sas/mocks/sasjsbuild/mock-snapshot.json services/web9
cd sas/mocks/sasjs
../../node_modules/.bin/sasjs b -t server-ci
@@ -242,8 +180,3 @@ jobs:
--message "$MESSAGE" \
"${SITE_ARGS[@]}" \
sas/mocks/sasjsbuild/mock
- name: Remove registry config
# written earlier in this job; nothing deletes it on the way out
if: always()
run: rm -f "$RUNNER_TEMP/npmrc"
+1 -2
View File
@@ -20,9 +20,8 @@ sasjsbuild
sasjsresults
.env*
.sasjsrc
client/.npmrc
*~
.lighthouseci
audit.log
tmp/
# raw parity captures - only the projection in parity/golden is committed
sas/mocks/test/parity/.captures/
+2 -7
View File
@@ -31,12 +31,7 @@ Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and
Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
- **SAS**: after creating or modifying any `.sas` files, run `npm run lint:sas` from the `sas/` directory. That script runs the `@sasjs/cli` version pinned in `sas/package.json`, so it matches CI; a global or `npx` `sasjs` may resolve a different `@sasjs/lint` and report a different rule set.
Rules are promoted to `error` in `sas/.sasjslint` one at a time as their findings reach zero, and only errors change the exit code - so the whole project must stay clean of the promoted rules, not just the files you touched. Warnings under rules not yet promoted are the remaining backlog; do not add to them in files you touch, and do not clear other files' findings in the same change unless that rule is the point of the change.
`sas/.sasjslint` carries `ignoredMacros: ["webout", "mpeinit", "mpeterm"]`. `webout` is the linter's own default - `sasjs compile` injects `%webout` into every service - and an explicit list replaces that default, so it has to be restated there. `mpeinit` and `mpeterm` are listed because every service is compiled with a serviceInit program (`sasjs/utils/serviceinitsas9|viya|server.sas`) that declares both macros in its own header, so a service body may call them without declaring them. Add a macro to that list only when it reaches a file the same way - injected by the compiler, through serviceInit, testSetUp or another implicit provider - never to silence a genuinely missing declaration.
`noBannedStrings` flags the literal words `error ` and `warning ` anywhere in a file, comments included. Reword prose; where the text has to stay (a real `%put`, a doxygen `@warning` tag), keep the log output identical with `%put %str(WARN)ING:` rather than a per-file override, and use `@sasjslint {"noBannedStrings": false}` only when the word itself is the content.
Hook scripts (`sasjs/services/hooks/*.sas`) run inside the service that includes them, so the macros they call come from that service's session. They carry a file-local `@sasjslint {"ignoredMacros": [...]}` rather than a macro list of their own, because end users copy and edit these scripts and should not have to maintain a declaration section.
`noMacroIfInOpenCode` is on, so `%if` must sit inside a `%macro` definition. Where a conditional has to stay in a service body, wrap the block in a small macro and call it unconditionally (`%macro build_staged(); ... %end; %mend build_staged; %build_staged()`), or resolve the value in a data step with `call symputx`. Two traps: a `%let` inside such a wrapper is local, so a macro variable that must survive the block has to be written with `call symputx(...,'G')`; and open-code `%if` itself needs SAS 9.4M5, which the code no longer relies on.
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
## No external assets
@@ -46,7 +41,7 @@ Data Controller must run entirely locally (offline / on-prem, no internet access
Agent-related content lives in `.agents/`: technical/agent-facing documentation goes in `.agents/docs/` (not `docs/`), and skills in `.agents/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agents/docs/`.
Skills installed via `npx skills add` are tracked in `skills-lock.json` and live under `.agents/skills/`. Repo-local custom skills (e.g. `dc-sas` for Data Controller SAS development, `dc-estate-testing` for estate deploys and test runs, `handsontable`, `hyperformula`) are kept here too. Avoid editing npx-managed skill files directly — use `npx skills update` instead.
Skills installed via `npx skills add` are tracked in `skills-lock.json` and live under `.agents/skills/`. Repo-local custom skills (e.g. `dc-sas` for Data Controller SAS development, `handsontable`, `hyperformula`) are kept here too. Avoid editing npx-managed skill files directly — use `npx skills update` instead.
## Code comments and test names
-270
View File
@@ -1,273 +1,3 @@
# [7.22.0](https://git.datacontroller.io/dc/dc/compare/v7.21.0...v7.22.0) (2026-11-05)
### Bug Fixes
* **browse:** accept legal folder names, cap the walk, and lock the read ([3572d56](https://git.datacontroller.io/dc/dc/commit/3572d56cfb0e438512fac202829c8409388b1dac))
* **build:** build the mock sites with a CLI whose runtime provides webout() ([b1eab98](https://git.datacontroller.io/dc/dc/commit/b1eab982006393bb23f8d19076a715055087d4fc))
* **build:** publish the changed-path decision and pin the matcher with tests ([ecc8702](https://git.datacontroller.io/dc/dc/commit/ecc87021c3e398f7d2b451971e9d618d3dd33d04))
* **build:** treat a sas/package.json bump as a Cypress input ([48df795](https://git.datacontroller.io/dc/dc/commit/48df79520217afbc55a5b7f6a67a500efc70db07))
* **client:** cancel a queued dropdown reposition on destroy ([590b841](https://git.datacontroller.io/dc/dc/commit/590b841f24329e6d1aefab881895f794fc3738ed))
* **client:** drop the synthetic window resize dispatches ([2535baa](https://git.datacontroller.io/dc/dc/commit/2535baa5f7ad169f48678df3f0f55ca18b5eaa9a))
* **client:** keep the ARIA pass on the viewbox and multi-dataset grids too ([cb30469](https://git.datacontroller.io/dc/dc/commit/cb30469e794b4fb4c87f5c382f36236b837e1855))
* **client:** restrict the table pass to Handsontable's own tables, add the viewer fallback ([46c6aec](https://git.datacontroller.io/dc/dc/commit/46c6aec37a836722fff61337fde4cab0d97723d8))
* **client:** scope the ARIA pass to each grid and drive it from the render hooks ([bf1e7d6](https://git.datacontroller.io/dc/dc/commit/bf1e7d6b1583ef32d492840c508d2a4b0fb1a1df))
* **client:** stop synthesising aria-labels; name the filter modal's submit in the test ([32d3d49](https://git.datacontroller.io/dc/dc/commit/32d3d49b59fd158c5790c9e1a4dd3cfc4f532877))
* **client:** strip only this grid's own wrapper, and pin the re-strip after a data load ([f282d1a](https://git.datacontroller.io/dc/dc/commit/f282d1a44bfe458c1e17415e1e3ba8b484f61518))
* **deploy:** ACTION cannot seed by default, and the probe stops overwriting ([e84cfaa](https://git.datacontroller.io/dc/dc/commit/e84cfaaabdf6fb91b0d58524f8a1ad7a4d2673f3))
* **deploy:** ask for the admin group before the staging directory on SASjs ([5c64691](https://git.datacontroller.io/dc/dc/commit/5c646913e453699ee8d5e7aedcae8b92b125ebde))
* **deploy:** await nothing, and encode the values in the makedata URL ([d67a0e5](https://git.datacontroller.io/dc/dc/commit/d67a0e5d5f06eac2d3aba220f43eb0c7f9a2e4af))
* **deploy:** drop the deprecated service-pack deploy from the setup screen ([59e09e2](https://git.datacontroller.io/dc/dc/commit/59e09e26a5f4432fbfb397287f1fdd4f656d761e))
* **deploy:** gate the browse actions on the settings file ([5eb8492](https://git.datacontroller.io/dc/dc/commit/5eb8492742a6ede252bc4f40dd33932700792718))
* **deploy:** make the folder picker work on both estates ([fb38309](https://git.datacontroller.io/dc/dc/commit/fb38309e98a6455ffe1d2cc501d5c1f9e0a694ba))
* **deploy:** match the documented SYSSCPL set and stop guessing at the cap ([5f338ff](https://git.datacontroller.io/dc/dc/commit/5f338ff43754ef58ba45c81d9929115c3f4715a3))
* **deploy:** read a URL-supplied ACTION before defaulting, and show locked folders ([addb4a5](https://git.datacontroller.io/dc/dc/commit/addb4a56ea4e7cac2bf92956fcc30b593bba7eee))
* **deploy:** stop the makedata poll outliving its screen, and align the release gates ([c808c41](https://git.datacontroller.io/dc/dc/commit/c808c41333a1dd46008c38fe60169585e03bfe68)), closes [#407](https://git.datacontroller.io/dc/dc/issues/407)
* **deploy:** unclamp the folder picker row and probe in the chosen context ([1330925](https://git.datacontroller.io/dc/dc/commit/133092502cf4832ab9df7472b690e646ed0765ff))
* **deps:** bump @sasjs/core to 7.0.1 so special missings can be restored ([3d2eefd](https://git.datacontroller.io/dc/dc/commit/3d2eefdd7b815e8dcff7989393a6055468859bbd)), closes [sasjs/core#499](https://git.datacontroller.io/sasjs/core/issues/499)
* **deps:** bump @sasjs/core to 7.1.3 so a filter abort names the clause ([c7c1815](https://git.datacontroller.io/dc/dc/commit/c7c181518cbdb209fc73830bb791e87186bcfc20))
* **editor:** a container resize must not clear an active sort ([5bd7b5d](https://git.datacontroller.io/dc/dc/commit/5bd7b5d724db1590d211c25fa6b5cd3568ead36c)), closes [#246](https://git.datacontroller.io/dc/dc/issues/246) [#246](https://git.datacontroller.io/dc/dc/issues/246)
* **editor:** drop the stale case hint, and narrow the strip to our own form ([bd2739c](https://git.datacontroller.io/dc/dc/commit/bd2739cde41f4e2132059d04b75f0e9ed4cf33d9))
* **editor:** one predicate for the CASE marker, and no stacking in the tooltip ([18b0546](https://git.datacontroller.io/dc/dc/commit/18b0546dcbe647a5a64209535c04132c4c68bd17))
* **embed:** a rejected mapping must not block the page ([d845b9e](https://git.datacontroller.io/dc/dc/commit/d845b9e9b74a87be907f2455e55dbf21c7dfdd36)), closes [dc/dc#404](https://git.datacontroller.io/dc/dc/issues/404)
* **embed:** read a month-name date from VA as a date, not a character value ([30cefcf](https://git.datacontroller.io/dc/dc/commit/30cefcf2dcfd78ccb963d86b36668248fec9fa1e))
* **getgroupmembers:** widen membername to $256 for long names ([196d6c9](https://git.datacontroller.io/dc/dc/commit/196d6c931d44527250f59dd1f89db259a24774ad))
* **lint:** enable noSingleAsteriskComments and noUnusedLibnames ([9071801](https://git.datacontroller.io/dc/dc/commit/9071801ac51ac1bc726c9430389d34bddc7cd1c0)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **lint:** make noBannedStrings an error and clear its 13 findings ([0b85f7b](https://git.datacontroller.io/dc/dc/commit/0b85f7bf1e59543ac195e37dd0a9e4c58b6befb6)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **lint:** make noUndeclaredMacros an error, and fix what it found ([af28a79](https://git.datacontroller.io/dc/dc/commit/af28a79bd56234f17545c5bc3df4969464779264)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **lint:** make noUnusedMacros an error and clear its 54 findings ([c8c0858](https://git.datacontroller.io/dc/dc/commit/c8c0858295f40ba734403ba24a4cae02b5464554)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **lint:** rename the short and mixed-case helper macros, and clear the last warnings ([7da67eb](https://git.datacontroller.io/dc/dc/commit/7da67eb17bccf2e6635d01a007872294b5d1708a)), closes [#376](https://git.datacontroller.io/dc/dc/issues/376)
* **makedata:** let the browse actions live as long as the service does ([912fae4](https://git.datacontroller.io/dc/dc/commit/912fae448b55130db13b3e684ea66b846d3d51c7))
* **mocks:** 0 is a closed TX_TO, a libref boundary, and the union of the row keys ([67d9cfd](https://git.datacontroller.io/dc/dc/commit/67d9cfd1bd9eb9512ddd9d222221dcf674552d9f))
* **mocks:** abort like mp_abort, lock like mp_lockanytable, derive saslibs ([ca60208](https://git.datacontroller.io/dc/dc/commit/ca602082ffc69f4cfca2de878fbf842a57efc194))
* **mocks:** answer the deploy screen's DIRLIST and WRITECHECK actions ([e1c3c6e](https://git.datacontroller.io/dc/dc/commit/e1c3c6e8727d928a10ddd2838563d13efcf33ddc))
* **mocks:** derive startupservice's maps and timezone instead of hardcoding ([87fd46c](https://git.datacontroller.io/dc/dc/commit/87fd46c825f592927df0e172bdfc91f0d13f81ef))
* **mocks:** fold the audit window, and append MPE_AUDIT under its lock ([5ecc7be](https://git.datacontroller.io/dc/dc/commit/5ecc7bebec104ef451e3d405c4c1728e39d8c2b0))
* **mocks:** keep the OMS and path separators the captures lost ([7d9d466](https://git.datacontroller.io/dc/dc/commit/7d9d466c3e9e9863e897bd1f663664274e660153))
* **mocks:** one sasDatetimeNow, a documented TX_TO=0, and a shaped fallback export ([e53c50d](https://git.datacontroller.io/dc/dc/commit/e53c50d4e2b4b8e3b3097bd415f2e2bbf940793d))
* **mocks:** read hook columns in either case, and guard duplicates first ([349a2f3](https://git.datacontroller.io/dc/dc/commit/349a2f314cbcbf6c706e201ccb5faa3412f934f4))
* **mocks:** read the metadata member from the response, not the function's arity ([09e9f19](https://git.datacontroller.io/dc/dc/commit/09e9f192c5dbc64f2812c2bac90b61d3e1e7346a))
* **mocks:** refuse a runtime whose webout() cannot carry column metadata ([482435f](https://git.datacontroller.io/dc/dc/commit/482435f318a3bd0dd33df02c6f4dc6335d7a1a6e))
* **mocks:** resolve the audit table once, record lower-case md5, and report a failed audit write ([1a9f594](https://git.datacontroller.io/dc/dc/commit/1a9f59478b059ac59c284f45cdd08fa6428e430c))
* **mocks:** run approval hooks, as mpe_runhook does ([f0208bb](https://git.datacontroller.io/dc/dc/commit/f0208bb0b00cce7e6fa1e5015aa95b4286509bdb))
* **mocks:** separate a body read failure from a missing showmeta floor ([744c89b](https://git.datacontroller.io/dc/dc/commit/744c89b91972f08130797a2650daf49cce0e6eb7))
* **mpe_abortmessage:** decide once whether the payload was escaped ([af89126](https://git.datacontroller.io/dc/dc/commit/af89126acf38d9aeee320b5784df87cdd48148e8))
* **mpe_abortmessage:** read program and state by name, and trim the prefix ([68b89ca](https://git.datacontroller.io/dc/dc/commit/68b89caf63678ace2bde61414b5b4bac2d419a9d))
* **mpe_assert_abort:** anchor the cut on the whole escaped separator ([e74a7ba](https://git.datacontroller.io/dc/dc/commit/e74a7ba1153f83eb436235fa17a90826e09af81f))
* **mpe_assert_abort:** compare the hook's message, not the log extract ([8b426c2](https://git.datacontroller.io/dc/dc/commit/8b426c27fd37a071a3df0417bbda3e1ec2edc291))
* **mpe_assert_abort:** compare the unescaped message, cut at the last marker ([e68d221](https://git.datacontroller.io/dc/dc/commit/e68d221354a3ab94d2975ca0949dee7b4f0c4137))
* **mpe_assert_abort:** cut at the separator, not the last marker ([4c7e869](https://git.datacontroller.io/dc/dc/commit/4c7e8693698ef7da6dd94e053cb6183a536004a3))
* **mpe_assert_abort:** name the separator and tolerate a carriage return ([7b09ade](https://git.datacontroller.io/dc/dc/commit/7b09ade97c4283fcff49a3e3d3b42a88085e575d))
* **mpe_assert_abort:** size the capture buffer for the whole message ([5d32de9](https://git.datacontroller.io/dc/dc/commit/5d32de9df97208e87e9baf7b60113ea155264510))
* **mpe_assert_abort:** strip the separator tail, not every trailing escape ([b29bc1e](https://git.datacontroller.io/dc/dc/commit/b29bc1e7ae6fa0c166e0649d96df1bc9aed9d54c))
* **mpe_execute:** assemble the abort text in a macro that can be tested ([e2e9103](https://git.datacontroller.io/dc/dc/commit/e2e91039cbf67cdc4306d4abb9d1db000c8ca1a3))
* **mpe_execute:** keep a child abort message intact when reporting it ([ebfd395](https://git.datacontroller.io/dc/dc/commit/ebfd39582fe6abe7249ce696cd79ff2a8962d53b))
* **mpe_execute:** keep the child message in the fallback, and test the doubled separator ([11587e2](https://git.datacontroller.io/dc/dc/commit/11587e2f2433fc68fc9e382b7fd3c3136a4edd34))
* **mpe_execute:** make the abort-text fallback reachable ([24b8cbe](https://git.datacontroller.io/dc/dc/commit/24b8cbe9dc3dc41a7fdd75d36de09dc7d7e4ca17))
* **mpe_execute:** mask a child abort message before it aborts the caller ([20dbf00](https://git.datacontroller.io/dc/dc/commit/20dbf00fb0b468549330cee049ff17bcdd99d5ea))
* **mpe_execute:** one symbol for the abort text, and no values in the call ([700b906](https://git.datacontroller.io/dc/dc/commit/700b906d69a20a73165335c8acbeb95f70dc1fea))
* **multi-dataset:** a sheet that failed to extract is not a clean finish ([3129648](https://git.datacontroller.io/dc/dc/commit/312964864ef5d908e181610245c46cd2e78b86cb))
* **multi-dataset:** show the file size and loading stage while the file loads ([45d6c02](https://git.datacontroller.io/dc/dc/commit/45d6c025c94946b7543f88ebaf92baae341d8a10)), closes [#121](https://git.datacontroller.io/dc/dc/issues/121)
* **report:** treat a blank PDF footer parameter as its default ([0f729d7](https://git.datacontroller.io/dc/dc/commit/0f729d7101ad14d556f7d356ffeb5366072e6be5))
* **test:** let the dirlist and writecheck results reach the caller on Viya ([ed678f0](https://git.datacontroller.io/dc/dc/commit/ed678f0b4616da0cef7f61b5e8b890cbdc26e912))
* **test:** run metanav.cy.ts in CI and open a metadata object ([02a8590](https://git.datacontroller.io/dc/dc/commit/02a8590310afce6c43a9218b67824cc6ac988710))
* **va-host:** bound the early replay even when the frame is not postable ([f6d5a04](https://git.datacontroller.io/dc/dc/commit/f6d5a0495e922ef3f3c14b0c291e4f8301a865ba))
* **va-host:** deliver in load mode, honour dataType, and survive odd prompt names ([7d4155d](https://git.datacontroller.io/dc/dc/commit/7d4155dc6ca0f3ec788a9e33719e9e29fe82d470))
* **va-host:** deliver the load-mount message once, and document report.json from the file ([90784b3](https://git.datacontroller.io/dc/dc/commit/90784b342f82d480fd67fdbcad5f65e8cd9c59e0))
* **va-host:** keep the deploy password off argv, and verify the app is served ([7c63a5c](https://git.datacontroller.io/dc/dc/commit/7c63a5c93f6efb64afa13e183fd2e8e91ff42679))
* **va-host:** post to the frame's own origin, and key the replay to the document ([9cec292](https://git.datacontroller.io/dc/dc/commit/9cec292681fbc8d331dc7e9ad063c350529de1a3))
* **viewer:** clamp over-tall PDF rows, always emit an otherwise-empty page ([10229d5](https://git.datacontroller.io/dc/dc/commit/10229d5efe3f1fa652cb4c168d40045bd3788771))
* **viewer:** defer the blob URL revoke, reject a selection inside skipped columns ([b5bb704](https://git.datacontroller.io/dc/dc/commit/b5bb704a4fb346c71c7159814b7e9bdc246e80a0))
* **viewer:** keep the PDF export working where hiddenColumns is not enabled ([f84d4ca](https://git.datacontroller.io/dc/dc/commit/f84d4ca7af55b577c644db28b0c375cc0ea4e9b5))
* **viewer:** review findings - CSV skipped column, PDF row cap before reading ([2c4be5e](https://git.datacontroller.io/dc/dc/commit/2c4be5e94b0f4590e96deac72bbaac36d5297784))
### Features
* **admin:** find and test a writeable location during deploy ([442c595](https://git.datacontroller.io/dc/dc/commit/442c595c2bb5983ba177d35b49d37c31a8a433d9))
* **deploy:** pick and validate the DC location from the deploy screens ([6eeab2e](https://git.datacontroller.io/dc/dc/commit/6eeab2ed16655c7e57ad4b8d3d678832fd225d14))
* **editor:** convert CASE rule values on entry ([c4eef49](https://git.datacontroller.io/dc/dc/commit/c4eef498b22af47258cd5cfac317e2020c8b7e31))
* **logs:** read the logs modal with the shared log parser ([aabc578](https://git.datacontroller.io/dc/dc/commit/aabc578d5ec787837450e4a12ade307b7cda08fc))
* **mocks:** add the control-table services the screens call ([a1615ee](https://git.datacontroller.io/dc/dc/commit/a1615ee8a711ccc4a4ad8a7b7638b3fd6a26b18e))
* **mocks:** build every JS stored program's response with webout() ([ce6bb5d](https://git.datacontroller.io/dc/dc/commit/ce6bb5d4cde50d47d2a5f7d2d3066ff086172603))
* **mocks:** emit the lineage, usernav and libinfo responses through webout() ([1f54fad](https://git.datacontroller.io/dc/dc/commit/1f54fad3fe7d0a42387f7e5e9fa661409f892a86))
* **mocks:** emit the metanav responses through webout() ([e1d877f](https://git.datacontroller.io/dc/dc/commit/e1d877f21d0882ee2220ce143583d2d6fb080cf8))
* **mocks:** emit the sas9 services through webout() ([7be3f84](https://git.datacontroller.io/dc/dc/commit/7be3f8407384babdf59979e91f706b5e8025579a)), closes [#461](https://git.datacontroller.io/dc/dc/issues/461)
* **mocks:** restore a load, and record the diffs it needs ([1a60c68](https://git.datacontroller.io/dc/dc/commit/1a60c689f4a1b7b3b374829a669638fc493a899f))
* **mocks:** use the server's webout() in the JS stored programs ([ccb67ed](https://git.datacontroller.io/dc/dc/commit/ccb67ed5a0981a9fb36f44214e364c08b3384e5c))
* **va-host:** emulate a VA report that embeds DC, as a deployable AppStream app ([2185db9](https://git.datacontroller.io/dc/dc/commit/2185db911d0333ef006cca7dc7a0f3d6aa7884a6))
* **viewer:** download a server-side PDF report ([67ca129](https://git.datacontroller.io/dc/dc/commit/67ca129c4db3eaead56a287811c578634ff59b19))
* **viewer:** export the grid as PDF from the context menu ([e58e735](https://git.datacontroller.io/dc/dc/commit/e58e7356103df350cf8ee1dd6e9eb59c8db57dd4))
### Reverts
* keep the helpers nested inside makedata_body ([4424171](https://git.datacontroller.io/dc/dc/commit/4424171b2f1cc89a7235c415557c72e0b9e7148a))
# [7.21.0](https://git.datacontroller.io/dc/dc/compare/v7.20.0...v7.21.0) (2026-10-29)
### Bug Fixes
* **client:** parse word-date values from VA parameters ([405b28d](https://git.datacontroller.io/dc/dc/commit/405b28dcccfe5994521bf63f0270d4877f544294))
* **client:** parse word-date values from VA parameters ([ce5b1aa](https://git.datacontroller.io/dc/dc/commit/ce5b1aa96175d0fd1f048fea6d127cbc743804f2))
* **makedata:** name the sasjs server group in the estate seed ([eeffa92](https://git.datacontroller.io/dc/dc/commit/eeffa92f9e973b323171bbefe2ef1cbc27e3aee7))
### Features
* **derived:** apply the core field scan in the MPE_DERIVED hook ([a4a0f9c](https://git.datacontroller.io/dc/dc/commit/a4a0f9cb2f7c0b6252405b71df64aae912d1b075))
# [7.20.0](https://git.datacontroller.io/dc/dc/compare/v7.19.0...v7.20.0) (2026-10-28)
### Bug Fixes
* **ci:** install doxygen for the SAS docs job ([f58925b](https://git.datacontroller.io/dc/dc/commit/f58925b29956f1f753f0a367fdcd0afe7f3f51a0))
* **ci:** keep foreign release assets, install jq first, pin the CLI ([27686d3](https://git.datacontroller.io/dc/dc/commit/27686d3aad5e34ace59ca1b735a17cca1a4025e1))
* **ci:** publish against the tag this job cuts, not releases/latest ([8888734](https://git.datacontroller.io/dc/dc/commit/888873421c3aeaf312d5ec45e3c805317c66976a))
* **ci:** publish docs after the assets, with the webdoc app password ([dd482c4](https://git.datacontroller.io/dc/dc/commit/dd482c4069202a0c6b9983129c19efc09bd160a7))
* **ci:** publish the code documentation with surfer 7 and app passwords ([25ffa71](https://git.datacontroller.io/dc/dc/commit/25ffa71adddba5e334f4a8c166f284d1be498fcd))
* **ci:** publish the typedoc to the webdoc site, not the app default ([c5808a1](https://git.datacontroller.io/dc/dc/commit/c5808a16eba0d29562fcc76f13ac4a33ab8da0b7))
* **ci:** rebuild and upload release assets on demand ([6486c80](https://git.datacontroller.io/dc/dc/commit/6486c80ed4f368bbf116c0ecdecc0e5e5f02075f))
* **ci:** use the lockfile and the pinned CLI in the docs job ([af1813f](https://git.datacontroller.io/dc/dc/commit/af1813f829861df41c89b512c824e8d3ebac7a4b))
* **ci:** verify the forge certificate on the release API calls ([cf7b21a](https://git.datacontroller.io/dc/dc/commit/cf7b21a8b61ec3bf7ebe21968ccf13ade264bc64))
* **cypress:** hold download out too, and retry once in CI ([c97e886](https://git.datacontroller.io/dc/dc/commit/c97e8866db9ee404b2118d24baf53fc7058debef)), closes [#395](https://git.datacontroller.io/dc/dc/issues/395) [#398](https://git.datacontroller.io/dc/dc/issues/398)
* **cypress:** register the deleteFolder task, restore the spec order, hold metanav out ([c803773](https://git.datacontroller.io/dc/dc/commit/c803773d3c36476d8263f01fc3b8344aa3503d44)), closes [#395](https://git.datacontroller.io/dc/dc/issues/395)
* **deploy:** do not offer the deploy wizard inside a report ([80e8911](https://git.datacontroller.io/dc/dc/commit/80e8911af8857fa2c417f028cd59b64a87233b9e))
* **deploy:** keep the app query when opening the standalone URL ([e51953c](https://git.datacontroller.io/dc/dc/commit/e51953ca8a6734e1649bad68cf4ad2057d677334))
* **deploy:** one definition of the standalone URL, and test the guards ([459ff2d](https://git.datacontroller.io/dc/dc/commit/459ff2da371375e996839d69deb40f681c356f1d)), closes [#TCS](https://git.datacontroller.io/dc/dc/issues/TCS)
* **deploy:** strip the embed parameters by name, and scope the timezone runs to the VA specs ([f7e7538](https://git.datacontroller.io/dc/dc/commit/f7e753863b61d8545bd0777d80267661b3c58670))
* **embed:** build quarter ends from the calendar, not minus 24 hours ([7e3045a](https://git.datacontroller.io/dc/dc/commit/7e3045a21b67053b76a81bfe836e31571ccbffbf))
* **embed:** defer the column check until metadata loads, report AND-ed pairs ([2623a3f](https://git.datacontroller.io/dc/dc/commit/2623a3fb4ba3bf19c00636b1717b53d5a2d54f3a))
* **embed:** log a parse-time mapping problem once ([0011f7e](https://git.datacontroller.io/dc/dc/commit/0011f7e933d85be8e33debe3a6ecb9000edaabe3))
* **embed:** make the va_parmap diagnostics say what they mean ([541a61b](https://git.datacontroller.io/dc/dc/commit/541a61ba08bc7256a911c50de7078859cc46c84e))
* **embed:** match a mapping on the parameter label only, and warn only when the values differ ([e9216b0](https://git.datacontroller.io/dc/dc/commit/e9216b08956d5599662259a2b05cc1524c458df5))
* **embed:** never build a clause from a value that did not convert, and say so in the banner ([bd3c220](https://git.datacontroller.io/dc/dc/commit/bd3c220af2678a2a3f8394fe3084ee03c23d0b15))
* **embed:** one parameter maps one column, and report typos on both sides ([f83dc35](https://git.datacontroller.io/dc/dc/commit/f83dc359e258f5d1cb9fc49f0eaab9748995d823))
* **embed:** record the parameter label only, so a name-keyed va_parmap is reported ([aa91daf](https://git.datacontroller.io/dc/dc/commit/aa91daf9f78c00cea486d0f885f8e634366bd1a0))
* **embed:** resolve transform values as local dates and give ranges whole days ([10157c1](https://git.datacontroller.io/dc/dc/commit/10157c11595685efdc206fe7b16a5cafa098f26d))
* **embed:** warn when a va_parmap entry is ignored ([411f6c3](https://git.datacontroller.io/dc/dc/commit/411f6c3354b7fbfec11fef3b174ae1f8066c92ba))
* **makedata:** pair the %else of the keepme block so it parses in open code ([c0ea810](https://git.datacontroller.io/dc/dc/commit/c0ea810766ebfda5ae5358ab14146fbcc07fcc8e))
### Features
* **embed:** map VA parameters to DC columns from the URL ([15a1f34](https://git.datacontroller.io/dc/dc/commit/15a1f34d5263503cac83b4fe4e52482777dc2f1f))
* **embed:** resolve va_parmap values through named date anchors ([88010c3](https://git.datacontroller.io/dc/dc/commit/88010c3980c2ea1c39bbb024ed32d63e495e61f0))
# [7.19.0](https://git.datacontroller.io/dc/dc/compare/v7.18.0...v7.19.0) (2026-10-28)
### Bug Fixes
* address the review findings on the harness and refresh changes ([36e4e88](https://git.datacontroller.io/dc/dc/commit/36e4e88eb134d3b48217d6dd185ea3c03f03fba5))
* **ci:** dispatch the demo deploy with a tag ref and the actions scope ([698dea6](https://git.datacontroller.io/dc/dc/commit/698dea6d6b7a8635313f11acfcc7a4f39a02bc99))
* **ci:** restore the gitleaks ignore entries for the mock licence pair ([0d58be9](https://git.datacontroller.io/dc/dc/commit/0d58be975d8eb2d7f1ab6a648f760151392c7973))
* **client:** open the SAS log page with noopener ([072a133](https://git.datacontroller.io/dc/dc/commit/072a1337020970b5b4ff8aa31719cc5ffec7701e))
* **client:** refuse a truncated table in the multi-table upload ([1fb4818](https://git.datacontroller.io/dc/dc/commit/1fb4818d215f538a0ac51772ab1f261aeb91f7e1))
* **cypress:** apply the licence key when the estate has none ([be8f445](https://git.datacontroller.io/dc/dc/commit/be8f445ae1051e132fb6eb5a69b45264255326b5)), closes [input#file-upload](https://git.datacontroller.io/input/issues/file-upload)
* **dc_execute:** use = comparisons, not IN, in the fail-fast guard ([fed43cb](https://git.datacontroller.io/dc/dc/commit/fed43cb07ef7f2afc685612a124dbc16d7618e21))
* **dc_getlibs:** exclude the DC libref from the dictionary scan ([f5aade5](https://git.datacontroller.io/dc/dc/commit/f5aade59760cc376b0f96eb4c892c8bca82dde14))
* **dc_refreshcatalog:** terminate the generated statements ([1e22476](https://git.datacontroller.io/dc/dc/commit/1e224763ba80500a112812a983801968e3751959))
* **derived:** a fileref with nothing written is a FAIL, not an error ([9b3cf50](https://git.datacontroller.io/dc/dc/commit/9b3cf50b9c3889786605f0f5d9032d84c50f7107))
* **derived:** address five review findings ([ccd468b](https://git.datacontroller.io/dc/dc/commit/ccd468b3358c1418ee6d8e198b088a0cf23b8c24))
* **derived:** carry the placement sequence in mpe_derived ([be50cb5](https://git.datacontroller.io/dc/dc/commit/be50cb5d082e788c6ae106bc1d02e73a8c7b9646))
* **derived:** close four gaps in the MPE_DERIVED hook ([9914790](https://git.datacontroller.io/dc/dc/commit/9914790777f92128c74ecce67e6c67ee48824b45))
* **derived:** close the RLS and search gaps on derived columns ([9ef813d](https://git.datacontroller.io/dc/dc/commit/9ef813d439b48e7fefd35c426e62b99e5943002a))
* **derived:** close the two gaps in the derived/RLS invariant ([422f766](https://git.datacontroller.io/dc/dc/commit/422f766cd43db4ce740e1890fd5df30790eaa7e3))
* **derived:** compare the outref as text, prove the file is empty ([7830b7b](https://git.datacontroller.io/dc/dc/commit/7830b7bcfa9806dde840ce8dd3e6a1887d0b38be))
* **derived:** do not drop a shadowed column from staging or downloads ([2d5de9a](https://git.datacontroller.io/dc/dc/commit/2d5de9a324a07a36edc0a4f74c8fad09fde07cbf))
* **derived:** do not duplicate a shadowed column in the grid payload ([6a1128a](https://git.datacontroller.io/dc/dc/commit/6a1128a46352f77c95dd02df9309f0984a1db2d1))
* **derived:** eight review findings ([561d7fc](https://git.datacontroller.io/dc/dc/commit/561d7fc3ed2a460835619962cf4dcb29d3dc87c7))
* **derived:** enforce the ACTIVE picker pair in the extracted check ([1863076](https://git.datacontroller.io/dc/dc/commit/1863076bc956b4ff94bf7b85b3dde0d6e3b54d7a)), closes [#366](https://git.datacontroller.io/dc/dc/issues/366)
* **derived:** enforce the documented limits, and handle derived filters in VIEW ([d3dec68](https://git.datacontroller.io/dc/dc/commit/d3dec682aafeb143fe983d6546337cb6490f5620))
* **derived:** filtering a format catalog no longer aborts the service ([469229a](https://git.datacontroller.io/dc/dc/commit/469229aecbaad304a835a5f229df111c37ff7c0d))
* **derived:** guard the bitemporal cut-off, coalesce the selectbox seed ([7fb981d](https://git.datacontroller.io/dc/dc/commit/7fb981daa91bce29c2d62c246185d01dcad08d6b))
* **derived:** guard the empty physical column list in the dep check ([5f5d868](https://git.datacontroller.io/dc/dc/commit/5f5d8681b9d78280a5200309917415e135c3ce5b))
* **derived:** hold a derived column read-only whatever the table's security ([5f443b0](https://git.datacontroller.io/dc/dc/commit/5f443b0b16a2abd3c11d96caa7a043911c29ef01))
* **derived:** ignore a re-added name when collecting inactive rows ([6b5bf3e](https://git.datacontroller.io/dc/dc/commit/6b5bf3e6be89b22e5a633c34b9aecbc8c7d48ee2))
* **derived:** ignore inactive rows and VIEW rules in the submit guards ([85c89ca](https://git.datacontroller.io/dc/dc/commit/85c89ca2a4e02112ba496ac36a222f33675544da))
* **derived:** keep targetds for plain datasets, test the empty file ([f8f6568](https://git.datacontroller.io/dc/dc/commit/f8f65681e1489f4ce94d9127d5e4c0c82798e960))
* **derived:** make the model enforce the required columns, and leave VAR_FMT optional ([7efbf31](https://git.datacontroller.io/dc/dc/commit/7efbf31ea5ac234bd8f924fdf89a95d101b3aff1))
* **derived:** only the delete marker is a reserved name ([af31c36](https://git.datacontroller.io/dc/dc/commit/af31c36b670ec63a2a4ba056bf6c4b28f6096e08))
* **derived:** open the statements file with a long record length ([ee31782](https://git.datacontroller.io/dc/dc/commit/ee31782fbff909b540162b1bdfca6f307208331b))
* **derived:** pair the filterds %else with %do/%end ([88a38db](https://git.datacontroller.io/dc/dc/commit/88a38db877dba9b98ccf50f0cc7242674654586b))
* **derived:** raise the VAR_ORDER ceiling to 10000 ([27ddcfb](https://git.datacontroller.io/dc/dc/commit/27ddcfb5759d6eb8f9880adc7c271a408e7741ad))
* **derived:** refuse dependencies on inactive rows, and keep the RLS scope guard coherent ([7799a30](https://git.datacontroller.io/dc/dc/commit/7799a300df82339683eea21e4a89ce7a6471c575))
* **derived:** reject a dependency on a row this submission deletes ([8806504](https://git.datacontroller.io/dc/dc/commit/88065049674c2cc0141eef22704b759f5b708b0f))
* **derived:** repair the validation rows for MPE_DERIVED ([9a047ad](https://git.datacontroller.io/dc/dc/commit/9a047ad42ec86df7d1eaf5058c0edde56016e88f))
* **derived:** review feedback - cover the empty response, assert the last row ([e0da9f1](https://git.datacontroller.io/dc/dc/commit/e0da9f1098db63185538398ca478696cc849da4b))
* **derived:** stop the hooks aborting on their own macro variables ([688550a](https://git.datacontroller.io/dc/dc/commit/688550a28d9794ac6b878926a0e1e2ef8f6e9335))
* **derived:** truncate the derived label on an even quote boundary ([1b5c4b3](https://git.datacontroller.io/dc/dc/commit/1b5c4b3ecd962a28e08fc5671b57dbf7ae0b8d9f))
* **derived:** verify on stripped values, and label with the variable name ([4d9ad38](https://git.datacontroller.io/dc/dc/commit/4d9ad38df1fa89b0d5a12214d703d9c1fe5295f2))
* **editor:** a blank truncation note is not a warning ([f182179](https://git.datacontroller.io/dc/dc/commit/f18217940365f27ae388fae087e6126931e1222c))
* **editor:** keep decimal values when an uploaded file is edited manually ([04ce186](https://git.datacontroller.io/dc/dc/commit/04ce1861e7821063b1be42c22f92f62aed509602))
* **getcolvals:** report an invalid value, not missing inputs ([4bcee70](https://git.datacontroller.io/dc/dc/commit/4bcee706ffaf1929bdd14bb2fb15111174ca3916))
* **getdata.test:** pin the derived filter to a libref, and spell the table as it is ([45dce7b](https://git.datacontroller.io/dc/dc/commit/45dce7b11b7a4399c1d0dc9c3e7a283b1cf59df1))
* **getdata:** clear the globals it declares before use ([359b570](https://git.datacontroller.io/dc/dc/commit/359b570b85aa2f66507e81ea2977243be4590f83))
* **getddl.test:** ask for a table, not a whole library ([059cdf1](https://git.datacontroller.io/dc/dc/commit/059cdf176a7ec556b45ea1b21d880c048eba14ca))
* **getrawdata:** declare mp_ds2md so the Markdown download can run ([661ee19](https://git.datacontroller.io/dc/dc/commit/661ee19cfa5f996bf01f2b11817039f38dbb9985))
* **lint:** clear the sasjs lint warnings on the files 7.19 touched ([355c46a](https://git.datacontroller.io/dc/dc/commit/355c46a7b64994d0146a88657dc5635593249138))
* **makedata:** read KEEPME from every input row, and keep it out of the Viya seed ([d626cb8](https://git.datacontroller.io/dc/dc/commit/d626cb8f2daf94fde7ff21d924b9a2ca4968d983))
* **mocks:** address the audit-pack review findings ([489819b](https://git.datacontroller.io/dc/dc/commit/489819b05111323ae1e34edda22a5446cdbc2d87))
* **mocks:** carry the zip flags into the central directory ([c4021a4](https://git.datacontroller.io/dc/dc/commit/c4021a42ffaed2af80c53aaa1455249190b339b8))
* **mocks:** clear the licence for published sites, not for the estate ([62f55db](https://git.datacontroller.io/dc/dc/commit/62f55db170185900063f7a431cd8f375ebabf930))
* **mocks:** copy the physical column list before deriving ([f411b7d](https://git.datacontroller.io/dc/dc/commit/f411b7d269381e3f7cab2d85b6d8f7d7d50ea4d1))
* **mocks:** derive before filtering in the viewer, and drop the columns ([60ec470](https://git.datacontroller.io/dc/dc/commit/60ec470404e5c279b58ac6a01768dfc83413b197))
* **mocks:** derive the retained key wherever rows are staged ([74060c0](https://git.datacontroller.io/dc/dc/commit/74060c0585fb8b217cb181ea747ad318f5238868))
* **mocks:** drop derived variables before staging ([73d25b1](https://git.datacontroller.io/dc/dc/commit/73d25b1220d574ef2547fbd9e6e3cf1ceda375ee))
* **mocks:** key rk_underlying tables on their business key ([347dbb5](https://git.datacontroller.io/dc/dc/commit/347dbb53b01c47bbd562de14d5ccbae7d96407c6))
* **mocks:** register MPE_DERIVED and seed its field rules ([ee3c878](https://git.datacontroller.io/dc/dc/commit/ee3c8787efef9cd716bdd2bf80f6cb6751461db9))
* **mocks:** scope the derived hook's graph to the submission ([6cad23f](https://git.datacontroller.io/dc/dc/commit/6cad23fce9ac0cf64c247c40f3b7eac875fd4cfb))
* **mocks:** search the physical columns only in the viewer ([293022d](https://git.datacontroller.io/dc/dc/commit/293022d46b27e096fd514cdda49f688612df2f5b))
* **mocks:** stop the mock advertising invalid licence keys ([fb36379](https://git.datacontroller.io/dc/dc/commit/fb36379520028baaf4bc3f08131bfc88deab3ac3))
* **mpe_assert_abort:** drop the unused line variable ([9c294c0](https://git.datacontroller.io/dc/dc/commit/9c294c0ab5ca8fe30a235553a298c1c68a61442f))
* **mpe_assert_abort:** take the abort's own MSG, tolerate a missing response ([0a52d20](https://git.datacontroller.io/dc/dc/commit/0a52d20c5fc27b4adde5fc8ed6f39a58c48f62ff))
* **mpe_execute:** fail fast when the child left no status ([a036386](https://git.datacontroller.io/dc/dc/commit/a03638667f1968c5909f496d954eeaa13f316763))
* **mpe_execute:** no apostrophe in the SAS 9 status note ([2204085](https://git.datacontroller.io/dc/dc/commit/220408538b26016bda45e48d619cdbc3e1cbcc59))
* **mpe_execute:** read an unknown state as unmeasured, not failed ([f2b1428](https://git.datacontroller.io/dc/dc/commit/f2b1428ec3099b2b6e3c6da6627530ef81fab201))
* name the startupservice launcher button "Show log" ([3445107](https://git.datacontroller.io/dc/dc/commit/3445107d9a91642b67e114bcfcd81c3ce47f3f0f))
* offer the launcher on the globvars failure modal too ([ad2da45](https://git.datacontroller.io/dc/dc/commit/ad2da453896a51f2cc7e49cebb6c84f7b47d7395))
* **refresh:** name each catalogue load uniquely in the audit trail ([46bacdd](https://git.datacontroller.io/dc/dc/commit/46bacdd6d9ea814bcd02a1ff8aa3f447baf02f64))
* **refresh:** one audit folder per refresh call ([56b6620](https://git.datacontroller.io/dc/dc/commit/56b6620ef402b288ce78a2a77662b651e310753e))
* **sites:** round-trip the committed licence before publishing ([4d462d3](https://git.datacontroller.io/dc/dc/commit/4d462d310f7df23e5d5229cb0702721b7ad41bbd))
* **test:** getdata test ([da30266](https://git.datacontroller.io/dc/dc/commit/da30266278c4fe27a72810cd5ed647cf3bd823af))
* **tests:** enable closeouts logging ([fdcd8fa](https://git.datacontroller.io/dc/dc/commit/fdcd8fa4b3e7a7a119c662974d70dc9eea80bdae))
* **tests:** read the work location from _SASJS_WORK ([033ffd3](https://git.datacontroller.io/dc/dc/commit/033ffd302502c7530fc44a314364f5cdb3be6ad2))
* **ui:** one error modal at a time ([81ccf75](https://git.datacontroller.io/dc/dc/commit/81ccf758e9dab0c7632ab50bf469e88b8c4c4420))
* **validatefilter:** build targetds without macro triggers in comments ([42355f1](https://git.datacontroller.io/dc/dc/commit/42355f17bc07bf4d44ba858984d83b9ed72cc0ba))
* **validatefilter:** drop the dead nobs assignment, keep targetds precedence ([337ea8e](https://git.datacontroller.io/dc/dc/commit/337ea8e5e021c6f2451fa3ad8396148744118fc9))
### Features
* **dc_getlibs:** dump the library list when debug is on ([582523d](https://git.datacontroller.io/dc/dc/commit/582523dc87a253b71824359224070ccb4746a386))
* **derived:** allow a variable to shadow a physical column ([12beeff](https://git.datacontroller.io/dc/dc/commit/12beeff69518376a042fbbc58332acc40d89d132))
* **derived:** declare VAR_DEPENDENCIES instead of VAR_ORDER ([813bce6](https://git.datacontroller.io/dc/dc/commit/813bce685691ad252043eeab31eb5460de81b756))
* **derived:** optional VAR_LABEL on MPE_DERIVED ([7c9ce9a](https://git.datacontroller.io/dc/dc/commit/7c9ce9a712137e9825a5f2ff52796d765423147b))
* **derived:** pickers, and an ACTIVE default, for MPE_DERIVED ([87e794f](https://git.datacontroller.io/dc/dc/commit/87e794f769fe420f214f43b141f0b41cf88eefee))
* **derived:** report the dependency cycle path ([cee1a95](https://git.datacontroller.io/dc/dc/commit/cee1a954ee97e17f297b7cf43ef0e5cfc6e393a6))
* **derived:** take any text in VAR_LABEL, but not a line break ([78e88a4](https://git.datacontroller.io/dc/dc/commit/78e88a41128b047c0276ccf647f128229152604c))
* **derived:** validate MPE_DERIVED at submit time ([0b010f2](https://git.datacontroller.io/dc/dc/commit/0b010f2c65ddada70d62ea83c706943c6c45756f))
* **derived:** validation rules and a VAR_TYPE dropdown for MPE_DERIVED ([aa6d50e](https://git.datacontroller.io/dc/dc/commit/aa6d50e1ea5fc2fe6be8aa9fa859b92d0a1061c5))
* **derived:** VARIABLE_ORDER, so a derived variable can build on another ([33d043e](https://git.datacontroller.io/dc/dc/commit/33d043ea92f007dca70c95d5363329d4447eeba9))
* **editor:** derived variables in the EDIT screen ([b0fd354](https://git.datacontroller.io/dc/dc/commit/b0fd354ecdbd7bf29196ed983ff3cce691affeed))
* **editor:** mark derived variables as computed in the EDIT grid ([6513d09](https://git.datacontroller.io/dc/dc/commit/6513d09f059de15c5304f99b3f8d898d46ea1be7))
* **editor:** serve a truncated selection with a warning instead of aborting ([34691dc](https://git.datacontroller.io/dc/dc/commit/34691dceecb63f952b23ad8acc5bc93753c6cfee))
* **editor:** show the derived column label in the header tooltip ([cfe6647](https://git.datacontroller.io/dc/dc/commit/cfe6647ca870c45a3d8abc2753ce7f070e3fdf4a))
* **mocks:** add getauditfile and the audit-folder artefacts ([752d944](https://git.datacontroller.io/dc/dc/commit/752d944c2091ef6d2624a15ab2e2e7be35608798))
* **mocks:** implement derived variables in the JS services ([b4fae88](https://git.datacontroller.io/dc/dc/commit/b4fae88ae13658607e4209449577cd42849d3a99))
* **mocks:** validate a submitted MPE_DERIVED row like the hook does ([5d183d1](https://git.datacontroller.io/dc/dc/commit/5d183d135085ea023f32e5b1d4992d997394a8fb))
* run startupservice in a new window from its error modal ([40f6133](https://git.datacontroller.io/dc/dc/commit/40f61331aad6bc8a52c551b924f0d1f1e6426cee))
* **sites:** mint a licence for the published sites ([7d9df71](https://git.datacontroller.io/dc/dc/commit/7d9df71fdb0ab265e745ed3b708038983e855a87))
### Reverts
* **derived:** no shadowing, and no self reference ([5af8e6f](https://git.datacontroller.io/dc/dc/commit/5af8e6fc359221586af74a685159dfd75a40ec45))
* **stagedata.test.4:** back to the MPE_LOADS evidence for now ([31aa859](https://git.datacontroller.io/dc/dc/commit/31aa8591538409fc1a3ef97d7474120094ce202d))
# [7.18.0](https://git.datacontroller.io/dc/dc/compare/v7.17.0...v7.18.0) (2026-10-19)
+1 -1
View File
@@ -8,7 +8,7 @@ This file is the shared glossary and orientation for the repo. When your output
- `client/` - the Angular frontend (TypeScript). Uses [Handsontable](.agents/skills/handsontable/SKILL.md) for the editable grid and [HyperFormula](.agents/skills/hyperformula/SKILL.md) for Excel-formula support. Lint with `npm run lint:check` from `client/`.
- `sas/` - the SAS backend. Services, macros, hooks and tests live under `sas/sasjs/**`; `sas/sasjsbuild/` is generated output (never hand-edit). Lint `.sas` files with `sasjs lint` from `sas/`.
- `.agents/docs/` - technical deep-dives (see below). `.agents/skills/` - task skills (handsontable, hyperformula, dc-sas for Data Controller SAS development, dc-estate-testing for deploying to and testing on the shared estate, plus npx-installed sasjs/ sas skills).
- `.agents/docs/` - technical deep-dives (see below). `.agents/skills/` - task skills (handsontable, hyperformula, dc-sas for Data Controller SAS development, plus npx-installed sasjs/ sas skills).
## Roles
+7 -40
View File
@@ -26,9 +26,8 @@ The standard [pre-commit](https://pre-commit.com) route is available as well, an
[SheetJS Pro Version](https://www.npmjs.com/package/sheetjs)
To auth for SheetJS Pro version you need to use their private registry. Keep that config out of
the repo - `client/.npmrc` is tracked and holds policy only (see below). Put the registry auth in
your user config, `~/.npmrc`:
To auth for SheetJS Pro version you need to use their private registry
Add `client/.npmrc` file with following content:
```
@sheet:registry=https://pylon.sheetjs.com:54111/
//pylon.sheetjs.com:54111/:_authToken="TOKEN-GOES-HERE"
@@ -47,30 +46,8 @@ echo "YOUR_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--decrypt ./libraries/sheet-crypto.tgz.gpg
npm i
npm run postinstall
```
The root and `sas/` `.npmrc` files set `ignore-scripts=true` too, and the root `package.json`
carries no install script - the root install is `npm ci` plus the subprojects, each run explicitly:
```bash
npm ci # root tooling: gitleaks, prettier, semantic-release
cd client && npm ci && npm run postinstall
cd ../sas && npm ci
```
The client `.npmrc` is now tracked and sets `ignore-scripts=true`, so dependency install hooks do not
run. Two of them matter here: the client's own `postinstall` - which generates
`src/environments/version.ts`, imported throughout the app - and cypress's binary download. Run
`npm run postinstall` after installing (and `npx cypress install` if you are running the Cypress
suite).
Registry credentials do **not** belong in `client/.npmrc`: it is a tracked policy file, and a token
written there is one `git add -A` away from being committed. Keep the token in `~/.npmrc`, npm's
default user config, or point npm at a file outside the repo with
`NPM_CONFIG_USERCONFIG=/path/to/npmrc`. The pre-commit hook rejects a staged `client/.npmrc` that
holds `_authToken`, `_auth` or `_password`.
[Handsontable](https://www.npmjs.com/package/handsontable)
The licence key comes from the backend licence: the decrypted licence carries `hot_license_key`,
@@ -109,7 +86,6 @@ RUN_TIMES=js
NODE_PATH=node
CORS=enable
WHITELIST=http://localhost:4200
HTTP_PORT=5000
```
`RUN_TIMES=js` means the server executes `.js` files as Stored Programs (no SAS binary needed). `CORS=enable` plus `WHITELIST` allows the Angular dev server on port 4200 to call the mock services on port 5000. Start the server:
@@ -118,7 +94,7 @@ HTTP_PORT=5000
./api-linux # or api-macos / api-win.exe depending on your platform
```
`HTTP_PORT` is not optional in SASjs Server 3.x: `PORT` and `PROTOCOL` are deprecated, and a server started without `HTTP_PORT` or `HTTPS_PORT` exits with "no listener is enabled". With it set, the server runs in desktop mode (no auth) on `http://localhost:5000`.
The server runs in desktop mode (no auth) on `http://localhost:5000` by default.
### 2. Deploy the mock services and streaming frontend
@@ -131,13 +107,6 @@ sasjs cbd -t server-ci
This compiles, builds, and deploys the JS mock services plus the Angular frontend (as a streaming app) to the SASjs Server. The `server-ci` target in `sas/mocks/sasjs/sasjsconfig.json` points to `http://localhost:5000` with appLoc `/Public/app/dc` and stream service name `clickme`.
The drive still needs seeding - the control library first, then the Cypress fixtures:
```
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json
sasjs request services/admin/createdemodata -t server-ci
```
After deploy, the streamed app is available at: `http://localhost:5000/AppStream/clickme/`
### 3. Run the Angular dev server (optional, for hot reload)
@@ -154,13 +123,11 @@ The app will be at `http://localhost:4200`. It calls the JS mock services on por
### How the mocks work
Each `.js` file in `sas/mocks/sasjs/services/` corresponds to a real SAS service (e.g. `public/startupservice.js` mimics `services/public/startupservice.sas`). `admin/createdemodata.js` is the exception: it has no SAS counterpart, because it exists only to give the test estate tables a deployed site would not have. The JS runtime predeclares `fs`, `_program` (the program's deployed path), `weboutPath` (the response body it writes) and `_webout`, and the services emit their members through the runtime's `webout()` - `webout('OPEN')`, one `webout('OBJ', name, rows)` (or `webout('ARR', name, rows)` for positional rows) per member, `webout('CLOSE')` - so the response envelope comes from the host and the file carries only what the SAS service returns. A few services still write the body themselves through `_webout`: the ones that emit plain text, CSV, DDL, HTML or a file download and set their own content headers (`admin/exportconfig`, `admin/exportdb`, `admin/makedata`, `auditors/getauditfile`, `public/getddl`, `public/getrawdata`). `sas/mocks/test/sasjs-webout.test.js` holds that list, so a service that goes back to writing its own body fails the suite. Some mocks are stateful: they persist data to a `mock-storage/` folder on the SASjs Drive (e.g. `licence.json`, `users.json`, `filter.txt`) using the predeclared `fs` module.
Each `.js` file in `sas/mocks/sasjs/services/` corresponds to a real SAS service (e.g. `public/startupservice.js` mimics `services/public/startupservice.sas`). The JS runtime predeclares `fs` and `_webout`; the mock assigns a JSON string to `_webout` that matches the structure the real SAS service would return. Some mocks are stateful: they persist data to a `mock-storage/` folder on the SASjs Drive (e.g. `licence.json`, `users.json`, `filter.txt`) using the predeclared `fs` module.
`admin/makedata` builds only what the SAS service builds: the control library defined by `mpe_makedatamodel.sas`, seeded with the rows `mpe_makedata.sas` inserts. `sas/mocks/test/makedata-parity.test.js` reads both macros and fails when the mock estate drifts from them, so the mock and a deployed site open with the same tables and the same configuration.
The mock data includes sample tables (`DC996664.MPE_X_TEST`, `MPE_X_NEW`, `MPE_X_FORMULA_TEST`, etc.) with realistic columns, validation rules (HARDREGEX, SOFTREGEX, HARDSELECT, READONLY, HIDDEN, ROUND, NUMBER_FORMAT), and dropdown values. This is sufficient to exercise the full editor grid, approval workflow, viewer, and admin screens.
The fixtures the Cypress suite needs but a DC install does not have live in `admin/createdemodata`, which runs after makedata: the `TESTDATA` demo library (`MPE_X_NEW`, `MPE_X_FORMULA_TEST`, `DERIVED_DEMO`, etc.) with its validation rules (HARDREGEX, SOFTREGEX, HARDSELECT, READONLY, HIDDEN, ROUND, NUMBER_FORMAT) and dropdown values, the `MPE_X_SEARCH` and `MPE_X_WIDE` viewer fixtures, and the `DC996664` multi-load library. It is safe to run twice, and refuses to run before makedata.
There is also a SAS 9 mock variant under `sas/mocks/sas9/` (target `sas9-mocks`) that mimics SAS 9 stored processes with the same JS approach but a different appLoc (`/User Folders/sasdemo/`). Its services are fixed recordings of the SAS 9 responses, emitted through `webout()` in the same way; the payloads are the SAS 9 ones (metadata URIs, `BUILTIN\Administrators` owners, `D:\...` paths, with their separators - the front end splits on `\`, so a value that arrives without it exercises the wrong branch). Only the payloads are SAS 9 flavour: the envelope around them (`_PROGRAM`, `SYSUSERID`, `SYSVLONG`, `SYSDATE`/`SYSTIME`) is whatever runtime serves the request, so do not read this variant for SAS 9 envelope values.
There is also a SAS 9 mock variant under `sas/mocks/sas9/` (target `sas9-mocks`) that mimics SAS 9 stored processes with the same JS approach but a different appLoc (`/User Folders/sasdemo/`).
### Re-deploying after changes
@@ -168,7 +135,7 @@ After changing mock services, re-run `sasjs cbd -t server-ci` from `sas/mocks/sa
### Demo and staging sites
A push to `release/**` publishes [staging.datacontroller.io](https://staging.datacontroller.io); a release tag (`v*`, created by the release job after its tests pass) publishes [demo.datacontroller.io](https://demo.datacontroller.io) - workflow file `.gitea/workflows/sites.yaml`, using the `DEMO_DATACONTROLLER_IO` Surfer app password (user `allan`). Both are sites on one multi-site Surfer instance; the demo is its default site. The job builds the mocks as a browser-hosted site (SASjs CLI >= 6.1, `streamConfig.mockWeb`), so the services run in a service worker and the drive lives in the visitor's IndexedDB - there is no server. The drive the site ships with is captured from a throwaway SASjs Server as `sasjsbuild/mock-snapshot.json`, and the two sites ship different ones: the demo runs `makedata` on that server first, so it opens with the control library and the sample tables; staging publishes without running it, so it opens on the deploy screen (DC routes there while `makedata` is still on the drive) and the visitor deploys the control library from the wizard. The estate carries no licence key, so CI runs on the free tier until a key is registered through the licensing screen (`admin/registerkey`) - the Cypress setup does that for the specs that need one. The Sites job writes a licence into the published drive, so demo and staging are fully usable. The pair lives in `sas/mocks/mock-licence.json`: the two values are one RSA key and are regenerated together, and the `.gitleaksignore` entries for it are line-scoped, so inserting a line above the pair re-fires the scanner rather than revealing a new secret.
A push to `release/**` publishes [staging.datacontroller.io](https://staging.datacontroller.io); a release tag (`v*`, created by the release job after its tests pass) publishes [demo.datacontroller.io](https://demo.datacontroller.io) - workflow file `.gitea/workflows/sites.yaml`, using the `DEMO_DATACONTROLLER_IO` Surfer app password (user `allan`). Both are sites on one multi-site Surfer instance; the demo is its default site. The job builds the mocks as a browser-hosted site (SASjs CLI >= 6.1, `streamConfig.mockWeb`), so the services run in a service worker and the drive lives in the visitor's IndexedDB - there is no server. It runs `makedata` on a throwaway SASjs Server first and ships that drive as `sasjsbuild/mock-snapshot.json`; without it the site opens with no data. The estate carries no licence key, so CI runs on the free tier until a key is registered through the licensing screen (`admin/registerkey`) - the Cypress setup does that for the specs that need one. The Sites job writes a licence into the published drive, so demo and staging are fully usable. The pair lives in `sas/mocks/mock-licence.json`: the two values are one RSA key and are regenerated together, and the `.gitleaksignore` entries for it are line-scoped, so inserting a line above the pair re-fires the scanner rather than revealing a new secret.
## GUI Elements
-5
View File
@@ -1,5 +0,0 @@
ignore-scripts=true
save-exact=true
# dependency install hooks are skipped: the client's own postinstall generates
# src/environments/version.ts and the hot icons, so run `npm run postinstall`
# after installing - see CONTRIBUTING.md for the full sequence
+1 -4
View File
@@ -1,10 +1,7 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"analytics": false,
"cache": {
"environment": "all"
}
"analytics": false
},
"version": 1,
"newProjectRoot": "projects",
-65
View File
@@ -1,4 +1,3 @@
import fs from 'fs'
import { defineConfig } from 'cypress'
export default defineConfig({
@@ -20,12 +19,6 @@ export default defineConfig({
env: {
hosturl: 'http://localhost:4200',
appLocation: '',
// The SASjs server the mock services run against, and the location the CI
// job streams the built app to (see sas/mocks/sasjs/sasjsconfig.json and
// CONTRIBUTING.md). `va-ddc-host.cy.ts` frames that build from the server's
// own /ddc-host page, so it needs both rather than `hosturl`.
sasjsServerUrl: 'http://localhost:5000',
streamedAppLocation: '/Public/app/dc',
site_id_SAS9: '70221618',
site_id_SASVIYA: '70253615',
site_id_SASJS: '123',
@@ -41,65 +34,7 @@ export default defineConfig({
e2e: {
video: true,
// One retry in a CI run, none interactively. `editor.cy.ts` test 28
// intermittently fails on "app-loading ... continuously found" - a slow load,
// not an assertion - and a single retry costs a few seconds where a red
// pipeline costs forty minutes. Failures that are real still fail twice, and
// the retried attempt's output is in the run log.
retries: { runMode: 1, openMode: 0 },
setupNodeEvents(on, config) {
// `download.cy.ts` clears the downloads folder before each test through
// `cy.task('deleteFolder')`. That task was registered in
// cypress/plugins/index.js, which Cypress 10+ never loads unless it is
// required from here - so the task had no handler and the spec failed on
// its first hook.
on('task', {
deleteFolder(folderName: string) {
return new Promise((resolve, reject) => {
fs.rm(folderName, { recursive: true, force: true }, (err) =>
err ? reject(err) : resolve(null)
)
})
},
/**
* Writes a copy of a fixture whose primary keys are renumbered from a
* random base, and returns the path to write it to.
*
* An upload that matches what the table already holds produces no
* changes, and a changeset with no changes has no approval waiting - so
* re-running a spec that uploads the same file finds nothing on the
* approve screen. That is not a UI fault, and a retry after a partial
* first attempt hits it every time. New keys make the upload a real
* insert whichever attempt gets there.
*/
makeUniqueExcel({ source, dest }: { source: string; dest: string }) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ExcelJS = require('exceljs')
const workbook = new ExcelJS.Workbook()
const base = 1000000 + Math.floor(Math.random() * 8000000)
return workbook.xlsx.readFile(source).then(() => {
const sheet = workbook.worksheets[0]
let rowNumber = 0
sheet.eachRow((row: any, index: number) => {
if (index === 1) return // header
row.getCell(1).value = base + rowNumber++
})
return workbook.xlsx.writeFile(dest).then(() => dest)
})
},
/** removes one file - deleteFolder would take the source fixture too */
deleteFile(filePath: string) {
return new Promise((resolve) => {
;(fs as any).rm(filePath, { force: true }, () => resolve(null))
})
}
})
// Pin the browser locale so locale-formatted cells (intl-date/time/datetime)
// render deterministically regardless of the runner's system locale.
on('before:browser:launch', (browser, launchOptions) => {
@@ -1,253 +0,0 @@
// Marks this file as an ES module so its top-level consts don't collide with
// other spec files under the TS type-checker.
export {}
//
// The deploy screen's folder picker. The estate the suite runs against is
// seeded, which hides the deploy screen (makedata has removed itself) and
// refuses DIRLIST / WRITECHECK once the settings file exists - so this spec
// reports the admin folder as unconfigured to land on the deploy screen, and
// answers the two probes itself.
const hostUrl = Cypress.env('hosturl')
const username = Cypress.env('username')
const password = Cypress.env('password')
/** The folder rows the mocked DIRLIST replies with. */
const FOLDERS = [
{
FILEPATH: '/tmp/DataController/alpha',
FILENAME: 'alpha',
FILE_OR_FOLDER: 'folder',
LEVEL: 1,
MSG: ' '
},
{
FILEPATH: '/tmp/DataController/beta',
FILENAME: 'beta',
FILE_OR_FOLDER: 'folder',
LEVEL: 1,
MSG: ' '
},
{
FILEPATH: '/tmp/DataController/shut',
FILENAME: 'shut',
FILE_OR_FOLDER: 'locked',
LEVEL: 1,
MSG: 'Permission denied'
}
]
const dirResponse = () => ({
dirinfo: [
{
PARENT: '/tmp/DataController',
SEP: '/',
TRUNCATED: 'N',
PLATFORM: 'UNIX',
DEPTH: 1
}
],
dirlist: FOLDERS
})
/**
* The picker's probes, answered with a fixed listing. A probe that reaches the
* real mock would refuse: the estate is configured, and the gate in the mock
* makedata answers 500 for DIRLIST after the settings file exists.
*/
const stubProbes = () => {
cy.intercept('POST', /\/SASjsApi\/stp\/execute/, (req) => {
let program = ''
try {
program = decodeURIComponent(
(req.url.match(/_program=([^&]+)/) || [])[1] || ''
)
} catch (e) {
program = (req.url.match(/_program=([^&]+)/) || [])[1] || ''
}
if (!program.includes('admin/makedata')) {
req.continue()
return
}
const body =
typeof req.body === 'string' ? req.body : JSON.stringify(req.body)
if (body.includes('DIRLIST')) {
req.reply(dirResponse())
return
}
if (body.includes('WRITECHECK')) {
req.reply({
checkwrite: [
{
PATH: '/tmp/DataController',
PATH_EXISTS: 'Y',
IS_FOLDER: 'Y',
WRITEABLE: 'Y',
MSG: ' '
}
]
})
return
}
req.continue()
})
}
/**
* Boots the app onto the deploy screen. The admin folder is reported as
* unconfigured (makedata present), so the app routes there instead of loading
* the startup service. Readiness is the picker's Browse button appearing -
* never a fixed wait, which turns a slow runner into an intermittent failure.
*/
const bootDeployScreen = () => {
cy.intercept('GET', /\/SASjsApi\/drive\/folder/, (req) => {
let url = req.url
try {
url = decodeURIComponent(req.url)
} catch (e) {
url = req.url
}
if (url.includes('services/admin')) {
req.reply({ files: ['makedata.js'], folders: [] })
return
}
req.continue()
})
stubProbes()
cy.visit(hostUrl, { timeout: 60000 })
// Wait for the page to reach a state we can act on: a visible login form,
// the evaluation agreement, or the deploy screen. A hidden login form stays
// in the DOM on some routes, so it only counts when it is visible.
const loginVisible = ($body: any) => {
const userInput = $body.find('input.username')[0]
return !!userInput && !Cypress.dom.isHidden(userInput)
}
const deployState = ($body: any) =>
$body.find('#TCS input[type="checkbox"]').length +
$body.find('#dcDirectory-browse').length
cy.get('body').should(($body) => {
expect(
loginVisible($body) || deployState($body) > 0,
'the app rendered a state to act on'
).to.be.true
})
cy.get('body').then(($body) => {
if (loginVisible($body)) {
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
}
})
// Accept the evaluation agreement when it is shown. Wait for the page to
// reach one of the two post-boot states first - the agreement card or the
// deploy screen - so the checkbox is only looked for once it can exist.
cy.get('body', { timeout: 60000 }).should(($body) => {
expect(
deployState($body),
'the deploy screen or its agreement card rendered'
).to.be.greaterThan(0)
})
cy.get('body').then(($body) => {
if ($body.find('#TCS input[type="checkbox"]').length) {
cy.get('#TCS input[type="checkbox"]').check({ force: true })
}
})
cy.get('#dcDirectory-browse', { timeout: 60000 }).should('exist')
}
context('deploy folder picker: ', function () {
it('1 | lists folders below the picker row, not over it', () => {
bootDeployScreen()
cy.get('#dcDirectory-browse').click()
cy.get('#dcDirectory-browser', { timeout: 30000 }).should('exist')
// The geometry the overlay bug broke, asserted on the live DOM: the row
// contains everything it rendered, and the panel starts below it.
cy.get('.dc-location-input').then(($row) => {
const row = $row[0].getBoundingClientRect()
const input = $row.find('input')[0].getBoundingClientRect()
expect(input.top).to.be.at.least(row.top - 0.5)
expect(input.bottom).to.be.at.most(row.bottom + 0.5)
const buttons = $row.find('button')
for (const button of buttons) {
const rect = button.getBoundingClientRect()
expect(
rect.bottom,
`${button.textContent} sits inside the picker row`
).to.be.at.most(row.bottom + 0.5)
}
})
cy.get('#dcDirectory-browser').then(($panel) => {
const panel = $panel[0].getBoundingClientRect()
cy.get('.dc-location-input').then(($row) => {
const row = $row[0].getBoundingClientRect()
expect(
panel.top,
'the folder panel starts below the picker row'
).to.be.at.least(row.bottom - 0.5)
})
// The panel is usable, not just positioned: the listing arrived and the
// rows are inside the panel.
const rows = $panel.find('.dc-folder')
expect(rows.length, 'folders listed').to.equal(3)
for (const row of rows) {
const rect = row.getBoundingClientRect()
expect(rect.top).to.be.at.least(panel.top - 0.5)
expect(rect.bottom).to.be.at.most(panel.bottom + 0.5)
}
expect($panel[0].textContent).to.contain('alpha')
expect($panel[0].textContent).to.contain('beta')
expect($panel[0].textContent).to.contain('Permission denied')
})
})
it('2 | validates the chosen folder and keeps the row layout', () => {
bootDeployScreen()
// Set the path explicitly. The field may arrive pre-filled from SYSSCPL,
// and a blank one would make validate() short-circuit with "Type or pick a
// folder first" - a failed assertion that says nothing about the layout.
cy.get('#dcDirectory').clear().type('/tmp/DataController')
// Validate answers below the row as well, and reports the writeable
// folder - the verdict must not overlay the buttons either.
cy.get('#dcDirectory-validate').click()
cy.get('#dcDirectory-validate-result', { timeout: 30000 }).then(
($verdict) => {
const verdict = $verdict[0].getBoundingClientRect()
cy.get('.dc-location-input').then(($row) => {
const row = $row[0].getBoundingClientRect()
expect(
verdict.top,
'the write check verdict starts below the picker row'
).to.be.at.least(row.bottom - 0.5)
})
expect($verdict[0].textContent).to.contain('is writeable')
}
)
})
})
+6 -47
View File
@@ -79,53 +79,12 @@ context('derived variables: ', function () {
it('4 | a derived column is marked read-only, and opens no editor', () => {
openEditor('TESTDATA.DERIVED_DEMO')
// a derived column is read-only AND computed, so the grid marks it apart
// from an ordinary read-only column - its own header/cell class, no
// readonlyCell dimming (editor.component.ts, afterGetColHeader /
// afterGetCellMeta). STATE is the control: same grid, a physical column.
headerCell('REGION').should('have.class', 'dc-derived-header')
headerCell('REGION').should('not.have.class', 'readonlyCell')
headerCell('STATE')
.should('not.have.class', 'dc-derived-header')
.and('not.have.class', 'readonlyCell')
getCellByHeaderAndRow(0, 'REGION').should('have.class', 'dc-derived-cell')
getCellByHeaderAndRow(0, 'STATE').should(
'not.have.class',
'dc-derived-cell'
)
// The computed block opens at whichever derived column comes first in the
// grid - MPE_DERIVED order, so LINE_VALUE here, not REGION. Assert the
// invariant rather than a name: exactly one derived header carries the
// marker, and it is the first one.
cy.get('.ht_clone_top .htCore thead tr th').then(($ths) => {
const derived = [...$ths].filter((th) =>
th.classList.contains('dc-derived-header')
)
expect(derived.length, 'derived headers').to.be.greaterThan(0)
expect(
derived[0].classList.contains('dc-derived-first'),
'the first derived header opens the block'
).to.be.true
derived
.slice(1)
.forEach(
(th) =>
expect(
th.classList.contains('dc-derived-first'),
'only the first derived header carries the marker'
).to.be.false
)
})
// the hover text carries the label, which is what says what the column is:
// the configured VAR_LABEL where there is one, otherwise the generated
// name=expression
headerCell('REGION_LABEL')
.should('have.attr', 'title')
.and('contain', 'Region and state')
headerCell('REGION')
.should('have.attr', 'title')
.and('contain', 'REGION=ifc')
// the editor marks a read-only column with this class, on the header and on
// its cells (editor.component.ts, afterGetColHeader / afterGetCellMeta), so
// this asserts the state rather than inferring it from something not
// happening. STATE is the control: same grid, not read-only.
headerCell('REGION').should('have.class', 'readonlyCell')
headerCell('STATE').should('not.have.class', 'readonlyCell')
enterEditMode()
+29 -178
View File
@@ -7,123 +7,41 @@ const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
const downloadsFolder = Cypress.config('downloadsFolder')
/* set by test 1 and removed afterwards, since it is written per attempt */
let generatedExcel = ''
import { deleteDownloadsFolder } from '../util/deleteDownloadFolder'
context('download files test: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
/* the estate ships no licence, so it runs on the free tier - and the free
tier disables file upload, which is the whole point of the audit case.
Asking for a licensed estate here means the spec does not depend on
another spec having planted a key first: the key persists in the estate's
config, so whether this case could run depended on the order the specs
were sharded into. */
cy.loginAndUpdateValidKey(true)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// beforeAll already logged in. Typing into the login form unconditionally
// failed with "the center of this element is hidden from view" whenever the
// session was live and the form was not on screen, so only fill it when it
// is actually shown.
cy.get('body').then(($body) => {
const usernameInput = $body.find('input.username')[0]
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
}
})
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
visitPage('home')
})
this.afterEach(() => {
deleteDownloadsFolder()
if (generatedExcel) {
cy.task('deleteFile', 'cypress/fixtures/excels/' + generatedExcel)
generatedExcel = ''
}
})
it('1 | downloads audit file', (done) => {
/* The approve grid renders a download button per table that is waiting for
approval, and a freshly seeded estate has none, so create one: upload a
fixture to the test table and submit it from the stage screen. The row is
then found on the approve screen by the table it names, because the stage
route does not carry that table's id.
The submitted row is left behind: CI recreates the appLoc and re-runs
makedata before the specs. On an estate reused by hand the rows accumulate
and the approve grid paginates, so a row pushed past the first page would
not be found - the table name still anchors the assertion, but it cannot
rescue a row that is not on the page. */
/* the fixture is renumbered before every attempt: an upload identical to
what the table already holds produces no changes, and a changeset with no
changes leaves nothing waiting for approval. On a retry that is exactly
what happens - the first attempt's rows are already in the table - so the
keys come from a random base and the upload is always an insert. */
generatedExcel = 'generated_' + Date.now() + '.xlsx'
visitPage('approve/toapprove')
cy.task('makeUniqueExcel', {
source: 'cypress/fixtures/excels/regular_excel.xlsx',
dest: 'cypress/fixtures/excels/' + generatedExcel
})
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.btn.btn-success')
.should('be.visible')
.then((buttons) => {
buttons[0].click()
const id = buttons[0].id
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
attachExcelFile(generatedExcel)
submitExcel()
/* the stage route carries a load reference, not the table id: an upload
lands on /stage/DC20261031-201657.470_601010_4449 rather than on
/stage/<table id>, so taking the id from the URL looked for a button that
could never exist. The row is found by the table it names, and the id the
download is named after is read from the button itself. */
visitPage('review/approve')
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
cy.get('clr-datagrid clr-dg-row', { timeout: longerCommandTimeout }).should(
($rows) => {
const texts = Array.from($rows).map((row: any) =>
row.innerText.replace(/\s+/g, ' ')
)
expect(
texts.some((text: string) => text.includes('MPE_X_TEST')),
`no approval row names MPE_X_TEST - rows: ${texts
.slice(0, 5)
.join(' | ')
.slice(0, 300)}`
).to.be.true
}
)
cy.get('clr-datagrid clr-dg-row')
.filter(':contains("MPE_X_TEST")')
.first()
.find('[aria-label="Download audit file"]')
.should('have.length', 1)
.then(($btn) => {
const tableId = $btn.attr('id') as string
expect(tableId, 'the approve button carries the table id').to.be.a(
'string'
)
cy.wrap($btn).click()
checkForFileDownloaded(tableId, 'zip', () => done())
checkForFileDownloaded(id, 'zip', () => done())
})
})
})
@@ -154,7 +72,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('EXCEL')
.select('Excel')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -174,7 +92,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('SAS')
.select('SAS Datalines')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -194,7 +112,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('SAS_DDL')
.select('SAS DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -214,7 +132,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('TSQL_DDL')
.select('TSQL DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -234,7 +152,7 @@ context('download files test: ', function () {
openDownloadModal(() => {
cy.get('select')
.select('PGSQL_DDL')
.select('PGSQL DDL')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
@@ -247,26 +165,6 @@ context('download files test: ', function () {
})
})
it('8 | downloads viewer PDF report', (done) => {
visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
openDownloadModal(() => {
cy.get('select')
.select('PDF')
.then(() => {
cy.get('.btn.btn-sm.btn-success-outline').then((button) => {
button.trigger('click')
const id = button[0].id
checkForFileDownloaded(id, 'pdf', () => done())
})
})
})
})
this.afterEach(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
})
@@ -300,23 +198,18 @@ const checkForFileDownloaded = (
}
const openDownloadModal = (callback?: any) => {
/* filterSide is the viewer's options dropdown; it no longer carries a
dropdown-toggle class - see filter-panel.cy.ts and row-cell-limits.cy.ts.
Its entries are div[clrDropdownItem], not buttons, so select Download by its
text the way filter-panel.cy.ts selects Filter. */
cy.get('.btn.btn-sm.btn-outline.filterSide', {
timeout: longerCommandTimeout
})
cy.get('.btn.btn-sm.btn-outline.filterSide.dropdown-toggle')
.click()
.then(() => {
/* the click handler is on the clrDropdownItem element, not on the span
holding the text, and the whole label is matched: more than one entry
may mention Download, and an ambiguous cy.contains fails outright */
cy.contains('clr-dropdown-menu [clrDropdownItem]', /^\s*Download\s*$/, {
timeout: longerCommandTimeout
}).click()
cy.get('clr-dropdown-menu button').then((buttons) => {
for (let button of buttons) {
if (button.innerText.toLowerCase().includes('download')) {
button.click()
if (callback) callback()
if (callback) callback()
}
}
})
})
}
@@ -355,45 +248,3 @@ const openTableFromTree = (libNameIncludes: string, tablename: string) => {
})
})
}
const fixturePath = 'excels'
/**
* Uploads a fixture to the open table. The editor renders the file input inside
* its upload modal, which the Upload button in the button bar opens, so that
* button is clicked first - by its label, not by position, so a change to the
* bar's button order cannot break this.
*/
const attachExcelFile = (excelFilename: string) => {
cy.get('.buttonBar button', { timeout: longerCommandTimeout })
.contains('Upload')
.click()
cy.get('input[type="file"]#file-upload', { timeout: longerCommandTimeout })
.should('have.length', 1)
.attachFile(`/${fixturePath}/${excelFilename}`)
/* a parsed upload raises the info modal with this title. The modal component
is the same one aborts use, so the title is what distinguishes them: any
other text here - an abort, a validation error - fails the case with its own
message rather than being clicked through. */
cy.get('.clr-abort-modal .modal-title', { timeout: longerCommandTimeout })
.should('be.visible')
.invoke('text')
.should('include', 'Table Found')
cy.get('.clr-abort-modal .modal-footer button').contains('Close').click()
}
const submitExcel = () => {
cy.get('.buttonBar button.preview-submit', {
timeout: longerCommandTimeout
}).click()
/* the URL is already a stage route before the submit lands, so waiting on it
proves nothing; the editor leaving preview mode is the signal that the
submit was accepted */
cy.get('.buttonBar button.preview-submit', {
timeout: longerCommandTimeout
}).should('not.exist')
}
+17 -437
View File
@@ -6,90 +6,44 @@ const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
// - chrome is hidden (same as embed=true),
// - the editor opens READ-ONLY (the user clicks Edit, as in normal use),
// - the Filter button is hidden - VA filters replace the filter builder,
// - the Add Row / Add Record buttons are hidden.
// - the editor opens in edit mode immediately (not read-only),
// - the only action button is a single Submit below the grid (stubbed in v1),
// - the top button bar and Add Record are hidden.
//
// The detailed PK+label data-merge logic is covered by the VaMessagingService
// unit spec; here we assert the deterministic UI layout, that pushing a VA
// postMessage does not break the page, and (case 3) that a `va_parmap` URL
// mapping filters the column it names end to end.
/**
* Assert on the row count the editor shows. The assertion runs inside a Cy
* `should`, so it retries until the grid has loaded: reading the text once would
* capture the count from before a filter is applied.
*/
const parseRows = (text: string): number => {
const match = /\(([\d,]+) rows?/.exec(text)
return match ? Number(match[1].replace(/,/g, '')) : NaN
}
const expectRows = (check: (rows: number) => void) =>
cy.get('h4', { timeout: longerCommandTimeout }).should(($h4) => {
check(parseRows($h4.text()))
})
/** Assert the grid shrank, without pinning a fixture-specific number. */
const expectFewerRowsThan = (baseline: number) =>
expectRows((rows) => {
expect(rows, 'filtered row count').to.be.lessThan(baseline)
})
/** Read the unfiltered row count from the app itself, retrying until it loads. */
const readRowCount = (): Cypress.Chainable<number> =>
expectRows((rows) => {
expect(rows, 'row count').to.be.greaterThan(0)
})
.get('h4')
.invoke('text')
.then(parseRows)
// unit spec; here we only assert the deterministic UI layout and that pushing a
// VA postMessage does not break the page.
context('embed=va (VA data-driven content) tests: ', function () {
// How many rows the table has unfiltered. Captured once, from the app itself.
let unfilteredRows = 0
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey()
// Baseline for the relative row-count assertions: open the table with no VA
// message and record what the editor itself reports.
cy.visit(`${hostUrl}${appLocation}/#/editor/DC_JSLIB.MPE_X_TEST`, {
timeout: 60000
})
readRowCount().then((count) => {
unfilteredRows = count
})
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
// A cy.visit that differs from the current URL only in the hash is a
// same-document navigation, so the app is not re-bootstrapped. Reload so
// each test starts from a fresh document (the previous test may have left
// ?embed=va in the URL, which the app reads once at init).
cy.reload()
visitPage('home')
})
it('1 | opens the editor in VA mode: chrome hidden, read-only, no Filter button', () => {
it('1 | opens the editor in VA mode: chrome hidden, editable, single bottom Submit', () => {
openEditorInVaMode('mpe_x_test', () => {
// Chrome hidden (same as embed=true)
cy.get('header.app-header').should('not.exist')
// Grid present, read-only: the user enters edit mode with Edit, as usual
// Grid present and in edit mode (read-only Filter/Edit/Upload bar absent)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('.btnCtrl button').contains('Edit').should('exist')
// The filter builder is replaced by VA filters
cy.get('.btnCtrl button').contains('Edit').should('not.exist')
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
// Not in edit mode, so no edit-mode controls
// Top edit-mode buttons hidden in VA mode
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
cy.get('.btnCtrl').contains('Submit').should('not.exist')
// Bottom Add Record hidden in VA mode
// Bottom Add Record hidden
cy.contains('button', 'Add Record').should('not.exist')
// Exactly one Submit button, below the grid, disabled in v1
cy.get('button').filter(':contains("Submit")').should('have.length', 1)
cy.get('button').contains('Submit').should('be.disabled')
})
})
@@ -115,382 +69,14 @@ context('embed=va (VA data-driven content) tests: ', function () {
// Editor still loads in VA mode after the message (chrome hidden, grid up)
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
cy.get('header.app-header').should('not.exist')
cy.get('.btnCtrl button').contains('Edit').should('exist')
})
})
it('3 | va_parmap filters a column from a parameter that is not its name (tab 1)', () => {
// Two DC instances over one table (one per report tab), each filtering the
// same DC column from its own report parameter. Without the URL mapping the
// parameter label is not a column name, so nothing would filter at all. The
// mapping here is written in lower case against an upper-case label and
// column, which pins the case-insensitivity of both sides end to end.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
// The applied filter names the MAPPED column, and the grid shrinks to
// the rows matching that value (Option 2 occurs twice in this table).
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DROPDOWN'
)
cy.get('.infoBar-text').should('contain.text', "'Option 2'")
expectFewerRowsThan(unfilteredRows)
},
'va_parmap=dropdown_tab1-some_dropdown'
)
})
it('4 | the other tab maps its own parameter to the same column', () => {
// Same table, same column, a different parameter label and value (Option 1
// occurs four times) - the per-instance URL mapping is what keeps the two
// tabs independent.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 1'
})
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DROPDOWN'
)
cy.get('.infoBar-text').should('contain.text', "'Option 1'")
expectFewerRowsThan(unfilteredRows)
},
'va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
)
})
it('5 | warns in the grid when a mapping names a column the table lacks', () => {
// Written by hand in the report's URL, so a typo must be visible to the
// report author: the parameter is skipped, and the reason is shown rather
// than leaving an unfiltered grid that looks like a working filter.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'VA parameter mapping problem'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
cy.get('.va-parmap-warning').should('contain.text', 'NOT_A_COLUMN')
// VA mode hides the app header, so the banner must paint inside the
// viewport on its own - assert the real box, not just its presence.
cy.get('.va-parmap-warning').should(($alert) => {
const rect = $alert[0].getBoundingClientRect()
expect(rect.width, 'banner width').to.be.greaterThan(100)
expect(rect.height, 'banner height').to.be.greaterThan(20)
expect(rect.top, 'banner top').to.be.at.least(0)
expect(rect.bottom, 'banner bottom').to.be.at.most(
Cypress.config('viewportHeight')
)
})
cy.screenshot('va-parmap-banner', { capture: 'viewport' })
// Nothing was filtered: the row count matches the unfiltered baseline.
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
},
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN'
)
})
it('6 | warns straight away when a mapping entry cannot be parsed', () => {
// No separator, so the entry never becomes a mapping - reported before any
// VA message arrives.
openEditorInVaMode(
'mpe_x_test',
() => {
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'it needs a "-" between the VA parameter and the column name'
)
},
'va_parmap=DROPDOWN_TAB1'
)
})
it('7 | warns when a mapping names a parameter VA is not sending', () => {
// Parameter-side typo: the mapping never applies, so without the banner this
// is indistinguishable from a working filter over every row.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
// One message only tells us this object's parameter set is still being
// discovered; the warning waits for a message that introduces no new name.
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'not.exist'
)
pushVaMessage({
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'names VA parameter "dropdown_tab_1", which VA is not sending'
)
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
},
'va_parmap=DROPDOWN_TAB_1-SOME_DROPDOWN'
)
})
it('8 | applies a mapping from the buffered message on the very first load', () => {
// VA posts once on iframe load, before the app bootstraps, so the first
// getdata is already filtered from that buffered message - with no column
// metadata available, the mapping is what names the column.
visitWithBufferedMessage(
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN',
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
},
() => {
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
"SOME_DROPDOWN = 'Option 2'"
)
expectFewerRowsThan(unfilteredRows)
cy.url().should('match', /\/editor\/[^/]+\/\d+/)
}
)
})
it('9 | a bad mapping on the first load still ends at the banner', () => {
// The deferred first load cannot check the column against metadata, so its
// saveQuery may fail and fall back to an unfiltered load - the banner is
// what stops that reading as a working filter.
visitWithBufferedMessage(
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN',
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 2'
},
() => {
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'NOT_A_COLUMN'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
// Deliberately no row-count assertion: this path cannot validate the
// column (no metadata yet), so the outcome depends on the platform - on
// SAS, mp_filtercheck aborts the save and the load falls back to
// unfiltered; the JS mock stores the filter and returns 0 rows. The
// banner is the part that must hold either way, because both outcomes
// are otherwise indistinguishable from a working filter.
}
)
})
it('10 | warns when two parameters filter the same column (an empty grid)', () => {
// Clauses are AND-ed, so two different values on one column produce no rows
// at all - the last silent-empty-grid path. Reported rather than rewritten,
// so the author decides whether to use one parameter carrying both values.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage(
{
name: 'pr1',
label: 'DROPDOWN_TAB1',
dataType: 'string',
value: 'Option 1'
},
{
name: 'pr2',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 2'
}
)
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'combined with AND'
)
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB2')
// The consequence: rows must match both clauses, and the two values
// differ, so none can match.
expectRows((rows) => expect(rows, 'matching rows').to.equal(0))
},
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN&va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
)
})
it('12 | a transform adds the previous quarter to the filter', () => {
// ADD(QUARTER_START,-1) is the reported requirement: one clause carrying the
// selected date and the same point one quarter earlier. 2026-10-01 = 24380,
// 2026-07-01 = 24288 in SAS days; nothing in the fixture matches either, so
// the assertion is on the clause rather than the row count.
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'SELECTED_DATE',
dataType: 'string',
value: '01OCT2026'
})
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
'contain.text',
'SOME_DATE IN ('
)
cy.get('.infoBar-text').should('contain.text', '24380')
cy.get('.infoBar-text').should('contain.text', '24288')
cy.get('h4').should('contain.text', '(0 rows')
},
'va_parmap=SELECTED_DATE-SOME_DATE:ADD(QUARTER_START,-1)'
)
})
it('13 | warns when a transform lands on a column it cannot apply to', () => {
openEditorInVaMode(
'mpe_x_test',
() => {
pushVaMessage({
name: 'pr1',
label: 'SELECTED_DATE',
dataType: 'string',
value: 'x'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'is not a date or datetime column'
)
cy.get('h4').should('contain.text', '(7 rows')
},
'va_parmap=SELECTED_DATE-SOME_CHAR:ADD(QUARTER_START,-1)'
)
})
it('11 | logs a parse-time problem once, however many messages arrive', () => {
// The parse problem is logged at app init; the editor's own loop must not log
// it again. The second entry is valid-but-missing, so the loop definitely runs
// and logs its own line - which is what makes this a real assertion.
cy.visit(
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&va_parmap=DROPDOWN_TAB1&va_parmap=DROPDOWN_TAB2-NOT_A_COLUMN`,
{
onBeforeLoad(win: any) {
win.__warns = []
const original = win.console.warn.bind(win.console)
win.console.warn = (...args: any[]) => {
win.__warns.push(args.join(' '))
original(...args)
}
}
}
)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
pushVaMessage({
name: 'pr2',
label: 'DROPDOWN_TAB2',
dataType: 'string',
value: 'Option 2'
})
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
'contain.text',
'NOT_A_COLUMN'
)
cy.window().then((win: any) => {
const warns: string[] = win.__warns || []
const parseLines = warns.filter((w) =>
w.includes('va_parmap "DROPDOWN_TAB1"')
)
const runtimeLines = warns.filter((w) => w.includes('NOT_A_COLUMN'))
expect(parseLines.length, warns.join(' | ')).to.eq(1)
expect(runtimeLines.length, warns.join(' | ')).to.eq(1)
cy.get('button').contains('Submit').should('exist')
})
})
})
/**
* Loads the editor with a VA message already in the pre-bootstrap buffer, the
* way VA's own postMessage arrives before Angular starts. A distinct pathname is
* used so this is a real document load: a cy.visit that differs only in the hash
* is a same-document navigation and would not re-bootstrap the app.
*/
const visitWithBufferedMessage = (
extraParams: string,
parameter: any,
callback?: any
) => {
cy.visit(
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&${extraParams}`,
{
onBeforeLoad(win: any) {
win.__vaLastMessage = {
t: Date.now(),
origin: win.location.origin,
data: {
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: [parameter]
}
}
}
}
)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
if (callback) callback()
}
/** Pushes one VA data-driven content message carrying the given parameters. */
const pushVaMessage = (...parameters: any[]) => {
cy.window().then((win) => {
win.postMessage(
{
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters
},
'*'
)
})
}
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
// re-visits the same route with ?embed=va so the app parses VA embed mode.
// `extraParams` is appended to that query string (e.g. va_parmap entries).
const openEditorInVaMode = (
tablename: string,
callback?: any,
extraParams?: string
) => {
const openEditorInVaMode = (tablename: string, callback?: any) => {
openTableFromTree(libraryToOpenIncludes, tablename)
cy.get('#hotTable', { timeout: longerCommandTimeout })
@@ -498,13 +84,7 @@ const openEditorInVaMode = (
.then(() => {
cy.url().then((url) => {
const separator = url.includes('?') ? '&' : '?'
const extra = extraParams ? `&${extraParams}` : ''
cy.visit(`${url}${separator}embed=va${extra}`)
// A cy.visit that differs from the current URL only in the hash is a
// same-document navigation, so the app is not re-bootstrapped and the
// embed parameter (read once at app init) never takes effect. Reload to
// bootstrap it, as the VA iframe load does.
cy.reload()
cy.visit(`${url}${separator}embed=va`)
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
'not.exist'
)
-50
View File
@@ -49,16 +49,6 @@ context('excel multi load tests: ', function () {
undefined,
(includes: boolean) => {
if (includes) {
// MPE_TABLES is left out of the submission, and the pane
// says why - as a sheet that could not be read or as one
// with no rows, depending on what the sheet utility
// reported. Either way it is named.
cy.get('#file-extraction-failures, #file-empty-sheets', {
timeout: longerCommandTimeout
})
.should('be.visible')
.and('contain.text', mpeTablesTable)
// MPE_TABLES sheet does not have data so 1 error image must be shown
hasErrorTables(1, (valid: boolean) => {
if (valid) done()
@@ -203,46 +193,6 @@ context('excel multi load tests: ', function () {
})
})
// The file's own status line: size and stage. The stages are momentary, so
// the dataset lookups are held open to make "Fetching the dataset details"
// assertable (and visible in the recording).
it('4 | Shows the file size and the loading stage while the file is processed', () => {
attachExcelFile('multi_load_test_1.xlsx', () => {
cy.contains('File size:')
.should('be.visible')
.invoke('text')
.should('match', /File size:\s*[\d.]+ MB/)
// The stage line lives in the file pane until the datasets are fetched,
// then in the no-table-selected pane while the sheets are extracted, so
// both ids are asked for - the element that carries it changes.
const STAGE = '#file-loading-stage, #file-extraction-stage'
// The sheet scan is quick; from then on the file waits for the user.
cy.get(STAGE, { timeout: longerCommandTimeout })
.should('be.visible')
.and('contain.text', 'File ready to load')
// The adapter posts to /SASjsApi/stp/execute/?_program=... - a glob on
// 'execute**' does not match the trailing slash, so match on the path.
cy.intercept({ url: /SASjsApi\/stp\/execute/ }, (req) => {
req.on('response', (res) => {
res.setDelay(1500)
})
})
cy.get('#continue-btn').trigger('click')
cy.get(STAGE, { timeout: longerCommandTimeout })
.should('be.visible')
.and('contain.text', 'Fetching the dataset details')
// Once every sheet has been extracted the stage gives way to the hint.
cy.get(STAGE, { timeout: longerCommandTimeout }).should('not.exist')
cy.contains('Please select a dataset on the left').should('be.visible')
})
})
this.afterEach(() => {
colorLog(`TEST END -------------`, '#3498DB')
})
-40
View File
@@ -419,46 +419,6 @@ context('excel tests: ', function () {
})
})
it('26 | Discarding an upload that fails validation clears the red cells', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// The loaded table's row count is what the grid must return to - the
// preview replaces the data source with the parsed file.
cy.get('h4.editor-title', { timeout: longerCommandTimeout })
.invoke('text')
.should('match', /\(\d+ rows?/)
.then((titleBefore) => {
const rowsBefore = titleBefore.match(/\(([\d,]+) rows?/)?.[1]
attachExcelFile('mixed_content_excel.xlsx', () => {
// The preview paints the cells that fail validation red - the state
// the user is looking at when they decide to abandon the file.
cy.get('#hotTable .htInvalid', {
timeout: longerCommandTimeout
}).should('have.length.greaterThan', 0)
cy.get('.buttonBar .btn-upload-preview.btn-outline-danger').click()
cy.get('clr-modal .modal-footer .btn-success-outline').click()
// Back to the loaded table, with no red cells left over. cancelEdit
// re-runs the HOT validation after restoring the data; without that
// the invalid cell meta survives the restore and the grid stays red.
cy.get('h4.editor-title', { timeout: longerCommandTimeout }).should(
'contain.text',
`(${rowsBefore} rows`
)
cy.get('#hotTable .htInvalid').should('not.exist')
// And the page is back in its normal read-only state, not the
// preview's button bar.
cy.get('.btnCtrl .btn-primary', {
timeout: longerCommandTimeout
}).should('contain.text', 'Edit')
})
})
})
// Large files break Cypress
// it ('? | Uploads Excel with size of 5MB', (done) => {
+2 -5
View File
@@ -12,7 +12,7 @@ const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Fixture: MPE_X_SEARCH, a 1000-row demo table built by the mock data builder
// (sas/mocks/sasjs/services/admin/createdemodata.js). It is deterministic, so every
// (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,
@@ -331,10 +331,7 @@ const setFilterWithValue = (
closeAutocompleteList('#vals_0_0')
cy.get('#datalist_vals_0_0').should('have.class', 'hidden')
// Live on the markup rather than a label: the filter modal's submit button
// has its text ("Ok") and no explicit aria-label, and the ARIA pass no
// longer synthesises one.
cy.contains('.filter-modal .modal-footer button', /^\s*Ok\s*$/).click()
cy.get('.filter-modal button[aria-label="Ok"]').click()
if (callback) callback()
}
+181 -167
View File
@@ -45,13 +45,21 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => {
keys = keysGen
gotoLicensingUpdate().then(() => {
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
})
cy.then(() => {
cy.wait(2000)
isLicensingPage((result: boolean) => {
if (result) {
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
cy.wait(2000)
}
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(10000)
}
visitPage('home')
waitForAppSettled()
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
@@ -78,9 +86,18 @@ context('licensing tests: ', function () {
keys = keysGen
console.log('keys', keys)
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
verifyLicensingWarning('This license key will expire in ', () => {
done()
})
@@ -103,18 +120,32 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => {
keys = keysGen
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
verifyLicensingWarning('(FREE Tier) - Problem with licence', () => {
openLicenceProblemDetails()
verifyLicensingPage(
'Licence key is expired - please contact',
(success: boolean) => {
if (success) done()
if (result) {
cy.wait(20000)
}
verifyLicensingPage(
'Licence key is expired - please contact',
(success: boolean) => {
if (success) {
verifyLicensingWarning(
'(FREE Tier) - Problem with licence',
() => {
done()
}
)
}
)
})
}
)
})
})
})
@@ -135,18 +166,32 @@ context('licensing tests: ', function () {
keys = keysGen
keys.activationKey = 'invalid' + keys.activationKey
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
verifyLicensingWarning('(FREE Tier) - Problem with licence', () => {
openLicenceProblemDetails()
verifyLicensingPage(
'Licence key is invalid - please contact',
(success: boolean) => {
if (success) done()
if (result) {
cy.wait(20000)
}
verifyLicensingPage(
'Licence key is invalid - please contact',
(success: boolean) => {
if (success) {
verifyLicensingWarning(
'(FREE Tier) - Problem with licence',
() => {
done()
}
)
}
)
})
}
)
})
})
})
@@ -167,18 +212,32 @@ context('licensing tests: ', function () {
keys = keysGen
keys.activationKey = keys.activationKey
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
verifyLicensingWarning('(FREE Tier) - Problem with licence', () => {
openLicenceProblemDetails()
verifyLicensingPage(
'SYSSITE (below) is not found',
(success: boolean) => {
if (success) done()
if (result) {
cy.wait(20000)
}
verifyLicensingPage(
'SYSSITE (below) is not found',
(success: boolean) => {
if (success) {
verifyLicensingWarning(
'(FREE Tier) - Problem with licence',
() => {
done()
}
)
}
)
})
}
)
})
})
})
@@ -188,9 +247,7 @@ context('licensing tests: ', function () {
it('6 | pasting an HTTP-format key (identical licence/activation text) on this secure connection warns before applying', (done) => {
visitPage('licensing/update')
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - switch to the legacy
// two-field layout, since this test targets that path specifically.
@@ -230,17 +287,25 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - earlier tests may have left the app on it (a
// bad key) or already activated (a good one), and this test should
// pass either way rather than silently no-op when it's the latter.
cy.then(() => {
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
inputCombinedKeyPage(combinedKey)
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
@@ -252,7 +317,10 @@ context('licensing tests: ', function () {
}
)
gotoLicensingUpdate()
if (!result) {
visitPage('licensing/update')
cy.wait(2000)
}
// proceed() generates the combined key asynchronously, then
// enqueues the actual page commands only once its promise
@@ -276,15 +344,15 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
cy.then(() => {
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
cy.get('button')
.contains('Paste as two separate keys instead')
.click()
@@ -307,7 +375,13 @@ context('licensing tests: ', function () {
cy.get('button.apply-keys').click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
@@ -321,7 +395,11 @@ context('licensing tests: ', function () {
// See test 7's own comment for why proceed() is bridged through
// cy.then() rather than called as a plain statement.
gotoLicensingUpdate().then(() => proceed())
if (!result) {
visitPage('licensing/update')
cy.wait(2000)
}
cy.then(() => proceed())
})
})
})
@@ -336,9 +414,11 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
cy.then(() => {
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
@@ -352,7 +432,13 @@ context('licensing tests: ', function () {
timeout: longerCommandTimeout
}).click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
@@ -366,7 +452,11 @@ context('licensing tests: ', function () {
// See test 7's own comment for why proceed() is bridged through
// cy.then() rather than called as a plain statement.
gotoLicensingUpdate().then(() => proceed())
if (!result) {
visitPage('licensing/update')
cy.wait(2000)
}
cy.then(() => proceed())
})
})
})
@@ -386,15 +476,15 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
cy.then(() => {
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
cy.get('.combined-key-form textarea', {
timeout: longerCommandTimeout
@@ -432,45 +522,16 @@ context('licensing tests: ', function () {
// See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement.
gotoLicensingUpdate().then(() => proceed())
if (!result) {
visitPage('licensing/update')
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
// The registration page belongs to an install that is not registered yet. An
// activated one that reaches it by URL has nothing to register, and accepting
// the EULA would post public/registeruser, which aborts for an
// already-registered user, so it is sent home instead of showing the EULA.
it('11 | an activated install is sent home from the registration page', (done) => {
visitPage('licensing/register')
cy.location('hash', { timeout: longerCommandTimeout })
.should('not.include', 'register')
.then(() => {
cy.get('.nav-tree', { timeout: longerCommandTimeout })
.should('exist')
.then(() => {
done()
})
})
})
// Only the actions in LicenseActions have a page. An unknown segment would
// render the licensing component with no action at all, so it goes home.
it('12 | an unknown licensing action is sent home', (done) => {
visitPage('licensing/this-action-does-not-exist')
cy.location('hash', { timeout: longerCommandTimeout })
.should('not.include', 'licensing')
.then(() => {
cy.get('.nav-tree', { timeout: longerCommandTimeout })
.should('exist')
.then(() => {
done()
})
})
})
if (testLicenceUserLimits) {
it('4 | User try to register when limit is reached', (done) => {
let keyData = {
@@ -489,10 +550,22 @@ context('licensing tests: ', function () {
}
generateKeys(keyData, (keysGen: any) => {
generateKeys(keyData2, (keysGen2: any) => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen2, () => {
cy.wait(2000)
const random = Cypress._.random(0, 1000)
const newUser = {
username: `randomusername${random}notregistered`,
@@ -509,15 +582,8 @@ context('licensing tests: ', function () {
// cy.get('.login-group button').click()
visitPage('home')
waitForAppSettled()
cy.get('#demo-banner', {
timeout: longerCommandTimeout
})
.should('contain', 'reached the limit')
.within(() => {
cy.get('a.update-key').click()
})
cy.wait(2000)
verifyLicensingPage(
'The registered number of users reached the limit specified for your licence.',
@@ -554,9 +620,17 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => {
generateKeys(keyData2, (keysGen2: any) => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
const random = Cypress._.random(0, 1000)
const newUser = {
username: `randomusername${random}`,
@@ -567,6 +641,7 @@ context('licensing tests: ', function () {
{ deleteAll: true, keep: username, newUsers: [newUser] },
() => {
updateLicenseKeyQuick(keysGen2, () => {
cy.wait(2000)
verifyLicensingWarning(
'The registered number of users exceeds the limit specified for your license.',
() => {
@@ -585,43 +660,6 @@ context('licensing tests: ', function () {
}
})
// A key change re-bootstraps the licence state: .app-loading while the startup
// data loads, then one of these states. Every page these specs act on renders
// one of them, so waiting for the overlay to clear and for one of these
// selectors is a sufficient settled state, and it costs nothing when the app is
// already there. .card-header is the licensing card, which is the only marker
// on the key/update/limit pages when there is no error to show and the install
// has no nav tree or banner yet.
const waitForAppSettled = () => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
cy.get('.nav-tree, #demo-banner, .card-header, .key-error, input.username', {
timeout: longerCommandTimeout
}).should('exist')
}
// The paste form exists only on licensing/update. Asking "are we anywhere
// under licensing/" also matches the error page the app shows when the current
// key is bad, which has no paste button at all. Returns the chainable so a
// caller's next commands are queued after the conditional visit - commands
// queued behind a bare call would run before the deferred navigation.
const gotoLicensingUpdate = () => {
waitForAppSettled()
return cy.location('hash').then((hash: string) => {
if (!hash.includes('licensing/update')) visitPage('licensing/update')
})
}
// The banner is the entry point to the licensing page: it names the licence
// problem and its action link opens the page that explains it.
const openLicenceProblemDetails = () => {
// The banner re-renders when the licence state arrives, so wait for the page
// to settle before clicking its link.
waitForAppSettled()
cy.get('#demo-banner a.update-key', { timeout: longerCommandTimeout })
.should('contain', 'More details')
.click()
}
const logout = (callback?: any) => {
cy.get('.header-actions .dropdown-toggle')
.click()
@@ -643,17 +681,6 @@ const acceptTermsIfPresented = (callback?: any) => {
.then(() => {
cy.get('#checkbox1')
.click()
.then(() => {
// Accepting the EULA posts public/registeruser, and the app
// reloads once it succeeds. The card is gone after that reload,
// so waiting for it covers the reload; a registration that is
// refused leaves the card in place and fails here.
cy.get('#checkbox1', {
timeout: longerCommandTimeout
}).should('not.exist')
waitForAppSettled()
})
.then(() => {
if (callback) callback(true)
})
@@ -673,26 +700,25 @@ const isLicensingPage = (callback: any) => {
}
const verifyLicensingPage = (text: string, callback: any) => {
// A bad key makes the app redirect to the licensing page. Asserting the URL
// and the error text together means a redirect that never comes fails here,
// rather than being read as a page that simply has no error.
cy.location('hash', { timeout: longerCommandTimeout }).should(
'match',
/licensing\/(?!register)/
)
// visitPage('home')
cy.get('.key-error', { timeout: longerCommandTimeout })
.should('contain', text)
.then(() => {
callback(true)
})
cy.wait(1000)
isLicensingPage((result: boolean) => {
if (result) {
cy.get('.key-error')
.should('contain', text)
.then((treeNodes: any) => {
callback(true)
})
}
})
}
const verifyLicensingWarning = (text: string, callback: any) => {
visitPage('home')
waitForAppSettled()
cy.get("div[role='alert'] .alert-text", { timeout: longerCommandTimeout })
cy.wait(1000)
cy.get("div[role='alert'] .alert-text")
.invoke('text')
.should('contain', text)
.then(() => {
@@ -701,9 +727,7 @@ const verifyLicensingWarning = (text: string, callback: any) => {
}
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - this helper exercises the
// two-part path specifically, so switch to it first.
@@ -718,13 +742,10 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
.trigger('input')
.should('not.be.undefined')
cy.get('button.apply-keys').click()
waitForAppSettled()
}
const inputCombinedKeyPage = (combinedKey: string) => {
cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button').contains('Paste licence').click()
// Combined is the default paste format - no toggle click needed here,
// unlike inputLicenseKeyPage's legacy two-field path.
@@ -749,7 +770,6 @@ const inputCombinedKeyPage = (combinedKey: string) => {
const updateUsersTable = (options: any, callback?: any) => {
visitPage('home')
waitForAppSettled()
openTableFromTree(libraryToOpenIncludes, 'mpe_users')
clickOnEdit(() => {
@@ -795,6 +815,7 @@ const updateUsersTable = (options: any, callback?: any) => {
// .type('{enter}')
// })
submitTable(() => {
cy.wait(2000)
approveTable(callback)
})
})
@@ -817,6 +838,7 @@ const changeLicenseKeyTable = (keys: any, callback?: any) => {
],
() => {
submitTable(() => {
cy.wait(2000)
approveTable(() => {
cy.reload()
if (callback) callback()
@@ -831,18 +853,10 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
isLicensingPage((result: boolean) => {
if (!result) {
visitPage('licensing/update')
cy.wait(2000)
}
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
// Applying keys navigates to '/' and reloads the app once the service
// accepts them, so the caller continues against the rebooted app rather
// than racing the reload.
cy.location('hash', { timeout: longerCommandTimeout }).should(
'not.include',
'licensing'
)
waitForAppSettled()
callback()
})
}
+36 -45
View File
@@ -1,51 +1,35 @@
export {}
const username = Cypress.env('username')
const password = Cypress.env('password')
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
const fixturePath = 'excels_general/'
context('metanav tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
cy.loginAndUpdateValidKey()
})
this.beforeEach(() => {
// beforeAll has already logged in. Typing into the login form here failed
// once the session existed - the form is not on screen, so Cypress reported
// "the center of this element is hidden from view" and the whole suite was
// skipped behind that hook.
cy.visit(hostUrl + appLocation)
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
visitPage('view/metadata')
})
it('1 | Opens metadata object', () => {
openFirstMetadataType()
/**
* Query the accordion at document level. The panel renders in the content
* area, while `.within(<tree node>)` scopes every following `cy.get` to
* that node alone - which finds nothing however long it retries.
*/
cy.get('.clr-accordion-header-button', { timeout: longerCommandTimeout })
.should('have.length.greaterThan', 0)
.first()
// Clarity puts the class on the button itself, so it is the click target.
.click()
// The panel reports its own open state, so the assertion is on the app's
// marker rather than on the content happening to be present.
cy.get('.clr-accordion-panel-open').should('have.length', 1)
cy.get('.clr-accordion-header-button')
.first()
.should('have.attr', 'aria-expanded', 'true')
// Opening the panel loads the object's details: the association tree and
// the attribute grid both come from `metanav/metadetails`.
cy.get('.clr-accordion-inner-content clr-tree-node', {
timeout: longerCommandTimeout
}).should('have.length.greaterThan', 0)
cy.get('clr-dg-row').should('have.length.greaterThan', 0)
cy.get('clr-dg-cell').contains('Name')
it('1 | Opens metadata object', (done) => {
openFirstMetadataFromTree(() => {
// BLOCKER
// For unkown reasons, .clr-accordion-header-button always null although it is present on the page.
cy.get('.clr-accordion-header-button').then((panelNodes: any) => {
panelNodes[0].querySelector('button').click()
})
})
})
this.afterEach(() => {
@@ -53,16 +37,23 @@ context('metanav tests: ', function () {
})
})
const openFirstMetadataType = () => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
// The metadata types are the tree nodes after the search box; the search box
// is its own node (`.search-node`), so selecting by position would tie the
// spec to that detail. The <p> is the element that owns the click handler.
cy.get('.nav-tree clr-tree > clr-tree-node:not(.search-node) p', {
timeout: longerCommandTimeout
})
.first()
.click()
const openFirstMetadataFromTree = (callback?: any) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let firstMetaNode
firstMetaNode = treeNodes[1]
cy.get(firstMetaNode).within(() => {
cy.get('.clr-treenode-content').click()
callback()
})
})
})
}
const visitPage = (url: string) => {
-349
View File
@@ -1,349 +0,0 @@
/**
* SAS Visual Analytics data-driven content, end to end.
*
* VA hands its DDC message to the embedded object as the document appears -
* before the app boots - so the app's earliest listener (src/assets/va-early.js)
* captures it, and the editor applies a pre-set filter on the very first load.
* Posting a message into a running page exercises a different path: it arrives
* after getdata, with column metadata available, so a date is formatted
* correctly there whatever the client does. Those two paths take the same
* clause builder with different inputs, so a value can be formatted correctly on
* one and not the other - which is why this spec pins the timing as well as the
* clause.
*
* sasjs/server's /ddc-host supplies the missing half: an iframe carrying VA's
* sandbox attribute set, and `timing=early` to deliver the message repeatedly
* from the moment the frame is created, so a copy lands before the app
* bootstraps. The app under test is the build streamed by the same server, so
* everything here runs against one origin and needs no SAS.
*
* Where the evidence is: the mock public/validatefilter stores what it is sent
* (`mpe_filtersource.json` on the drive) and returns the FILTER_HASH it computed
* for that clause, so the rows are read back by the hash this run's own response
* named. That is what makes the assertion about the clause DC built rather than
* about a request payload; and selecting by hash rather than emptying the store
* first is what keeps the spec out of every other spec's way - it clears nothing,
* so nothing has to run before or after it. A store that already holds this clause
* holds the same rows, and one that holds a different clause cannot be mistaken for
* this one. The host page's own log is the second witness - it records the capture
* before the frame finishes loading, which is the timing that makes the case
* meaningful. The frame's internals are deliberately not read: Cypress proxies the
* framed document, so its `contentWindow` and navigation timings cannot be
* trusted, and the drive is the authority anyway.
*/
const serverUrl = (
Cypress.env('sasjsServerUrl') || 'http://localhost:5000'
).replace(/\/$/, '')
const appLoc = Cypress.env('streamedAppLocation') || '/Public/app/dc'
const table = 'DC_JSLIB.MPE_X_TEST'
const filterSourcePath = `${appLoc}/data/DC_JSLIB/mpe_filtersource.json`
const appUrl = `${serverUrl}/AppStream/clickme/#/editor/${table}?embed=va`
const longerCommandTimeout = Cypress.env('longerCommandTimeout') || 50000
interface StoredClause {
FILTER_HASH: string
VARIABLE_NM: string
OPERATOR_NM: string
RAW_VALUE: string
PROCESSED_DTTM: number
}
/**
* A VA message carrying one parameter. `dataType: 'string'` is what a date
* parameter carries when VA displays it with a month name, and on the
* metadata-less path this type and the value's shape are all the builder has to
* go on. The `dataType: 'number'` variant is covered by the unit spec, where the
* dropped-clause branch can be driven directly.
*/
const vaMessage = (label: string, value: string, dataType = 'string') => ({
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: [{ name: 'pr1', label, dataType, value }]
})
const ddcHostUrl = (message: object) =>
`${serverUrl}/ddc-host?` +
new URLSearchParams({
url: appUrl,
message: JSON.stringify(message),
timing: 'early'
})
/** Everything the mock validatefilter has stored. */
const readStoredClauses = (): Cypress.Chainable<StoredClause[]> =>
cy
.request({
url: `${serverUrl}/SASjsApi/drive/file`,
qs: { _filePath: filterSourcePath },
failOnStatusCode: false
})
.then((res: any) => {
if (res.status === 404) return []
const body =
typeof res.body === 'string' ? JSON.parse(res.body) : res.body
return (body?.rows ?? []) as StoredClause[]
})
/**
* The candidate strings a request body carries, any of which could hold the CSV
* row: the raw text, the percent-decoded text, each form field's value, and every
* string leaf of a JSON body.
*
* Matching the raw text alone is not enough - the same row arrives wrapped in a
* multipart part, as `filterquery=...` with `%2C` for every comma, or inside a
* parsed object - and widening the delimiters instead lets a quoted literal
* through, which is the thing this guard exists to refuse.
*/
const bodyTexts = (body: any): string[] => {
const raw = typeof body === 'string' ? body : JSON.stringify(body ?? '')
const texts = [raw]
let decoded = raw
try {
decoded = decodeURIComponent(raw)
} catch (error) {
// not valid percent-encoding: the raw text is all there is
}
if (decoded !== raw) texts.push(decoded)
for (const part of decoded.split('&')) {
const index = part.indexOf('=')
if (index >= 0) texts.push(part.slice(index + 1))
}
let parsed: any = null
try {
parsed = JSON.parse(raw)
} catch (error) {
parsed = null
}
const collect = (value: any) => {
if (typeof value === 'string') texts.push(value)
else if (value && typeof value === 'object')
Object.values(value).forEach(collect)
}
collect(parsed)
return texts
}
/** The fields of a CSV row: split on the separators, nothing else. */
const csvFields = (text: string): string[] => text.split(/[,\r\n]/)
/**
* Whether the request carries a `filterquery` row for `variable`.
*
* The name has to BE a field of the candidate, not appear inside one: a
* RAW_VALUE, an SQL fragment, a quoted literal, or another variable whose name
* merely contains this one is therefore not counted as another delivery of this
* clause.
*/
const carriesClause = (body: any, variable: string): boolean =>
bodyTexts(body).some((text) =>
csvFields(text).some((field) => field.trim() === variable)
)
/**
* The FILTER_HASH the service returned for the clause in `body`.
*
* It is the service's own hash of the query, so it names this run's clause
* exactly: a clause that changed produces a different hash and the stored rows
* for the old one no longer match.
*/
const filterHashFrom = (body: any, variable: string): string => {
// The response body arrives as text, not an object: the service writes a JSON
// string and the adapter passes it through unchanged.
let payload: any = body
if (typeof body === 'string') {
try {
payload = JSON.parse(body)
} catch (error) {
throw new Error(
`the validatefilter response for ${variable} was not JSON: ` +
String(body).slice(0, 300)
)
}
}
const rows = Array.isArray(payload?.result) ? payload.result : []
const hash = rows[0]?.FILTER_HASH
if (!hash) {
throw new Error(
`the validatefilter response carried no FILTER_HASH for ${variable}: ` +
String(body).slice(0, 300)
)
}
return String(hash)
}
/** The clause stored for `variable` under `hash`, retried until it is written. */
const storedClauseFor = (
hash: string,
variable: string,
attempt = 0
): Cypress.Chainable<StoredClause> =>
readStoredClauses().then((rows: StoredClause[]) => {
const stored = rows.filter(
(row) =>
String(row.FILTER_HASH) === hash &&
String(row.VARIABLE_NM).toUpperCase() === variable
)
if (stored.length) return stored[stored.length - 1]
if (attempt > 60) {
throw new Error(
`no clause for ${variable} under ${hash} (60 reads, ${rows.length} rows)`
)
}
cy.wait(250)
return storedClauseFor(hash, variable, attempt + 1)
})
/**
* Mount the host page, then assert the two facts that make a case valid: the app
* captured the message, and it did so before the frame finished loading (the
* app's earliest listener runs at document parse, so this is the window a report
* refresh exercises). A capture that only lands after load takes the metadata
* path, which formats a date correctly whatever the client does, and so would
* prove nothing about the metadata-less path.
*/
const mountWithEarlyMessage = (message: object) => {
cy.visit(ddcHostUrl(message))
cy.get('#log', { timeout: longerCommandTimeout }).should(($log) => {
const lines = $log.text()
const captured = lines.indexOf('early replay: the app captured the message')
const loaded = lines.indexOf('frame loaded')
// The log tail goes into the message: a failure here means either the app
// never exposed its buffer or the frame never loaded, and the host page says
// which. Without it the failure is only "expected -1 to be above -1".
expect(
captured,
`the app captured the message. Log: ${lines.slice(-500)}`
).to.be.greaterThan(-1)
expect(loaded, 'the frame finished loading').to.be.greaterThan(-1)
expect(captured, 'capture happened before the frame loaded').to.be.lessThan(
loaded
)
})
}
/**
* Run one case and return the clause the service stored for it.
*
* The hash is read from the response to this case's own validatefilter call, so
* the rows are selected by what this run sent rather than by whatever the store
* happened to hold. Nothing is cleared first, which is why this spec needs no
* position in the run order.
*
* Which response is "this case's" is decided by the clause itself: the request
* body carries the filterquery CSV, so a call naming a different variable is
* ignored and cannot overwrite the hash. Two calls carrying the same variable
* would be the same clause, but they would also mean the harness is delivering
* twice - so the case fails on that count rather than silently taking the last.
*/
const applyVaFilter = (
message: object,
variable: string
): Cypress.Chainable<StoredClause> => {
const capture: { calls: number; hash: string; error: Error | null } = {
calls: 0,
hash: '',
error: null
}
cy.intercept({ method: 'POST', url: '**/*validatefilter*' }, (req) => {
const forThisCase = carriesClause(req.body, variable)
req.continue((res) => {
if (!forThisCase) return
capture.calls++
// An exception thrown here surfaces as a Cypress uncaught error rather
// than as this case failing, so it is kept and rethrown in the chain.
try {
capture.hash = filterHashFrom(res.body, variable)
} catch (error) {
capture.error = error as Error
}
})
}).as('validatefilter')
// The apply's own request sequence: validatefilter, then the data load for the
// filtered route. Waiting for the load is the app's completion signal, so the
// duplicate check below does not rest on elapsed time.
cy.intercept({ method: 'POST', url: '**/*getdata*' }).as('getdata')
mountWithEarlyMessage(message)
return cy
.wait('@validatefilter', { timeout: longerCommandTimeout })
.then(() => {
if (capture.error) throw capture.error
// Before anything is read back: an empty hash means no delivery matched
// this case - every body was refused by carriesClause, or the response
// carried no FILTER_HASH - and storedClauseFor('') would spend its retries
// and then report a missing row, naming neither cause.
expect(
capture.hash,
'validatefilter FILTER_HASH for this case'
).to.not.equal('')
return storedClauseFor(capture.hash, variable)
})
.then((clause: StoredClause) =>
// Counted after the data load rather than after a pause: the app issues the
// load only once the filter request has been answered, so a second POST
// from the same apply has been made by then, and nothing here depends on
// how fast the runner is.
cy.wait('@getdata', { timeout: longerCommandTimeout }).then(() => {
expect(
capture.calls,
`validatefilter calls carrying ${variable} in this case`
).to.equal(1)
return clause
})
)
}
context('VA data-driven content through /ddc-host:', () => {
it('1 | stores a month-name date as a date literal', () => {
// `August 02, 1971` is how VA displays SOME_DATE=4231 under WORDDATE. A
// month-name value formatted as character cannot be compared with a numeric
// date column: on a live estate that is the PROC SQL type clash inside
// mp_filtervalidate, and on this mock estate it is the quoted literal this
// assertion rejects.
applyVaFilter(vaMessage('SOME_DATE', 'August 02, 1971'), 'SOME_DATE').then(
(clause: StoredClause) => {
expect(clause.OPERATOR_NM).to.equal('=')
expect(clause.RAW_VALUE).to.equal("'02AUG1971'd")
}
)
})
it('2 | stores a DATE9 date as a date literal (control)', () => {
// DATE9, the control: it separates a parsing problem in the month-name case
// from a harness that stopped delivering dates at all.
applyVaFilter(vaMessage('SOME_DATE', '01DEC2021'), 'SOME_DATE').then(
(clause: StoredClause) => {
expect(clause.RAW_VALUE).to.equal("'01DEC2021'd")
}
)
})
it('3 | stores a character parameter as a quoted string (control)', () => {
applyVaFilter(vaMessage('SOME_DROPDOWN', 'Option 2'), 'SOME_DROPDOWN').then(
(clause: StoredClause) => {
expect(clause.RAW_VALUE).to.equal("'Option 2'")
}
)
})
})
+37 -116
View File
@@ -793,47 +793,11 @@ const openViewViewboxes = () => {
// cy.contains retries until a matching node appears/updates - the open
// viewbox's own list entry can render before its table-name text has
// actually loaded, so a one-shot .then() lookup here would race it.
const openViewboxConfig = (viewbox_tablename: string, attempt = 1) => {
const target = viewbox_tablename.toLowerCase()
const heading = () =>
cy.contains('p', 'Configuration (', { timeout: longerCommandTimeout })
// "Configuration (#2 DC_JSLIB.MPE_ALERTS)" -> "dc_jslib.mpe_alerts". A
// substring test would accept another library's same-named table (this
// estate seeds MPE_X_TEST under two libraries) and any name that merely
// contains the target - which is the mix-up this check exists to catch.
const configuredLibDataset = (text: string) => {
const match = text.match(/#\d+\s+([^\s)]+)/)
return match ? match[1].trim().toLowerCase() : ''
}
const headingNames = (text: string) => {
const configured = configuredLibDataset(text)
return configured === target || configured.endsWith(`.${target}`)
}
// The panel heading names the viewbox it is configuring, so it - not the
// row - is what the test waits on.
heading().then(($heading) => {
if (attempt === 1 && headingNames($heading.text())) return
cy.contains('.open-viewbox', viewbox_tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
// loadData() re-selects the viewbox whose data just landed, about 600-800
// ms after it lands. Checking earlier than that passes while the panel is
// still on the target, then the re-select moves it and the test body reads
// the other viewbox's columns - so stay past the window, then check.
cy.wait(800)
heading().then(($after) => {
if (headingNames($after.text())) return
if (attempt < 4) return openViewboxConfig(viewbox_tablename, attempt + 1)
expect($after.text().toLowerCase()).to.contain(target)
})
})
const openViewboxConfig = (viewbox_tablename: string) => {
cy.contains('.open-viewbox', viewbox_tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
@@ -977,90 +941,47 @@ const setFilterWithValue = (
if (callback) callback()
}
// Expands the library and clicks each table's link, retrying the expansion if
// the picker's tree re-renders underneath it. The previous version walked the
// rendered node list and paused 300ms before looking at each one, which on a
// library of this size was ~13s of the ~20s a viewbox test took.
const openTableFromViewboxTree = (
libNameIncludes: string,
tablenames: string[]
) => {
// The argument is a fragment of the library's own name; escaping it keeps a
// dot or a bracket in that name from matching more than the node meant.
const libraryName = new RegExp(
libNameIncludes.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'i'
)
cy.get('.add-new clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
const libraryNode = () =>
cy.contains('.add-new clr-tree > clr-tree-node', libraryName, {
timeout: longerCommandTimeout
})
// Scoped to the matched library: the same table name can exist under another
// library that a previous step left expanded.
const tableLinks = ($body: JQuery) =>
$body
.find('.add-new clr-tree > clr-tree-node')
.filter((_, node) => libraryName.test(node.textContent || ''))
.first()
.find('.clr-treenode-link')
const expandLibrary = () =>
libraryNode().find('.clr-tree-node-content-container p').first().click()
const clickTableLink = (tablename: string, attempt = 1) => {
cy.get('body').then(($body) => {
const visible = tableLinks($body).filter(
(_, el) =>
(el.textContent || '')
.toLowerCase()
.includes(tablename.toLowerCase()) && Cypress.dom.isVisible(el)
)
if (visible.length) {
cy.wrap(visible.first()).click()
return
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
// the tree re-renders as the library's tables load, and a re-render
// collapses the node - expand again and look once more
if (attempt < 4) {
expandLibrary()
cy.wait(300)
clickTableLink(tablename, attempt + 1)
return
}
expect(
visible.length,
`a visible picker link for ${tablename}`
).to.be.greaterThan(0)
cy.get(viyaLib).within(() => {
cy.get('p')
.click()
.then(() => {
cy.get('.clr-treenode-link').then(async (innerNodes: any) => {
for (let innerNode of innerNodes) {
for (let tablename of tablenames) {
await pause(300)
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
}
}
}
})
})
})
}
})
}
expandLibrary()
for (const tablename of tablenames) {
clickTableLink(tablename)
}
// Each pick adds its row to the open list asynchronously, so wait for the
// rows before returning a list the caller can select from.
for (const tablename of tablenames) {
cy.contains('.open-viewbox', tablename, {
matchCase: false,
timeout: longerCommandTimeout
})
}
// A viewbox's data arrives after its row, and loadData() re-selects the box
// it just loaded ~600ms later (two nested setTimeout calls in the
// component) whenever something is already selected. Selecting inside that
// window is undone by it, which sends the next step to the other viewbox's
// column list - so let the window pass. openViewboxConfig() asserts the
// selection it makes, so a lost click still fails at the click.
cy.wait(800)
const pause = (ms: number) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(null)
}, ms)
})
}
const visitPage = (url: string) => {
+2 -30
View File
@@ -1,19 +1,6 @@
module.exports = {
ci: {
collect: {
// One pass per URL. lhci defaults to three and takes the median, which
// triples the wall clock of the audit (13.6 minutes measured across
// seventeen runs) to steady a number that only has to clear a fixed
// threshold. Raise this if a score starts flapping.
//
// The trade to know about: with one pass there is no median per URL, so a
// single slow or unlucky navigation moves its own score rather than being
// averaged away, and the recovery is a re-run rather than a fix. The
// assertions are set with that in mind - performance at 0.4, and
// accessibility at 0.85 against the 0.87-0.96 a seeded estate measures
// (issue 497), not at the 1.00 an empty one used to score - so a flap
// here means the runner was busy, not that the app regressed.
numberOfRuns: 1,
settings: {
preset: 'desktop',
chromeFlags: '--no-sandbox --disable-dev-shm-usage'
@@ -25,6 +12,7 @@ module.exports = {
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664',
'http://localhost:5000/AppStream/clickme/#/view/data/DC996664.MPE_X_TEST',
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups',
'http://localhost:5000/AppStream/clickme/#/view/usernav/groups/1',
'http://localhost:5000/AppStream/clickme/#/view/usernav/users/1',
'http://localhost:5000/AppStream/clickme/#/home/excel-maps',
@@ -40,25 +28,9 @@ module.exports = {
},
assert: {
assertions: {
// 0.85, not 1: the audit now runs against a seeded estate (the mocks are
// deployed and makedata/createdemodata have run), so it measures the app
// with data in it rather than empty states. That measurement is 0.87 to
// 0.96 across the seventeen URLs - the defects it reports are real and
// tracked in issue 497. The gate is the median of those seventeen runs,
// so a single busy route does not turn a fixed threshold into a coin
// flip: a run 0.09 under the floor passes when the median clears it, and
// that is the trade this threshold makes (the performance assertion
// below accepts the same one). Use aggregationMethod 'pessimistic' -
// lhci's worst run - if the floor must hold for every route instead;
// accessibility clears 0.85 that way today, performance would not. The
// floor goes back to 1 as those findings are cleared, and 497 carries
// that condition.
//
// Per-URL overrides are not available here: lhci strips the fragment, so
// all seventeen routes are one URL to it ("1 URL(s), 17 total run(s)").
'categories:accessibility': [
'error',
{ minScore: 0.85, aggregationMethod: 'median' }
{ minScore: 1, aggregationMethod: 'median' }
],
'categories:performance': [
'error',
+64 -73
View File
@@ -22,9 +22,10 @@
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "18.1.2",
"@sasjs/adapter": "5.1.2",
"@sasjs/utils": "4.3.1",
"@sasjs/utils": "4.0.0",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35",
"base64-arraybuffer": "^0.2.0",
"browserify-cipher": "^1.0.1",
"buffer": "^5.4.3",
@@ -49,6 +50,7 @@
"save-svg-as-png": "^1.4.17",
"stream-browserify": "3.0.0",
"stream-http": "3.2.0",
"text-encoding": "^0.7.0",
"tslib": "^2.3.0",
"vm": "^0.1.0",
"webpack": "^5.91.0",
@@ -80,7 +82,6 @@
"@types/randombytes": "^2.0.3",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"archiver": "8.0.0",
"core-js": "^2.5.4",
"cypress": "^15.19.0",
"cypress-file-upload": "^5.0.8",
@@ -5207,12 +5208,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@isaacs/nonrecursive-glob-match": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
"license": "BlueOak-1.0.0"
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -8727,9 +8722,9 @@
}
},
"node_modules/@sasjs/utils": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.3.1.tgz",
"integrity": "sha512-P98zjjfrhVJ7/ubKTWTn8Oiyb4/a+SIfVQOIa9oMXZrsRsZ5RTLj40sl6jmv1iCxbsqfziOrnNc0Yu6gx5Qp1A==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-4.0.0.tgz",
"integrity": "sha512-lARoCnRM4gSVf8ShoP5elHi7W28K/14MK037dQ0ABgMlP3+r6OuhAZYwK/a70tED47tKxyIoCAKLdP6jrG0ewQ==",
"license": "ISC",
"dependencies": {
"@fast-csv/format": "5.0.7",
@@ -9612,6 +9607,12 @@
"@types/node": "*"
}
},
"node_modules/@types/text-encoding": {
"version": "0.0.35",
"resolved": "https://registry.npmjs.org/@types/text-encoding/-/text-encoding-0.0.35.tgz",
"integrity": "sha512-jfo/A88XIiAweUa8np+1mPbm3h2w0s425YrI8t3wk5QxhH6UI7w517MboNVnGDeMSuoFwA8Rwmklno+FicvV4g==",
"license": "MIT"
},
"node_modules/@types/tmp": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz",
@@ -9876,9 +9877,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9945,17 +9946,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1"
"@typescript-eslint/scope-manager": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/typescript-estree": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -9970,15 +9971,15 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.72.1",
"@typescript-eslint/types": "^8.72.1",
"@typescript-eslint/tsconfig-utils": "^8.72.0",
"@typescript-eslint/types": "^8.72.0",
"debug": "^4.4.3"
},
"engines": {
@@ -9993,15 +9994,15 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1"
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -10012,9 +10013,9 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -10030,17 +10031,17 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/project-service": "8.72.1",
"@typescript-eslint/tsconfig-utils": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"@typescript-eslint/project-service": "8.72.0",
"@typescript-eslint/tsconfig-utils": "8.72.0",
"@typescript-eslint/types": "8.72.0",
"@typescript-eslint/visitor-keys": "8.72.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -10059,14 +10060,14 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
"version": "8.72.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/types": "8.72.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -15245,9 +15246,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
@@ -19279,7 +19280,6 @@
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
@@ -19601,9 +19601,9 @@
}
},
"node_modules/mocha/node_modules/argparse": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz",
"integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
"integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==",
"dev": true,
"funding": [
{
@@ -22269,22 +22269,6 @@
"url": "https://github.com/sponsors/yqnn"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.3.tgz",
"integrity": "sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/nonrecursive-glob-match": "^1.0.3",
"brace-expansion": "^5.0.12"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/readdir-scoped-modules": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz",
@@ -24247,6 +24231,13 @@
"b4a": "^1.6.4"
}
},
"node_modules/text-encoding": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
"integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
"deprecated": "no longer maintained",
"license": "(Unlicense OR Apache-2.0)"
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -25180,9 +25171,9 @@
}
},
"node_modules/vis-data": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.6.tgz",
"integrity": "sha512-6NjsyqWL18ithgkpPqfYGvwx7JlrxgO0MkMJdZus4eovFl6ebUjNcn/UEzgm5NWvCAz4psUgkvcdLyAXFxeeDg==",
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.5.tgz",
"integrity": "sha512-tRQKcTGaclo60rTY1j4v7BgD9v5jfD3wl+JgR1q6I4AI7go3QH5OjMCLIuZPpL2sf4IRMlwyPlRLnK4m+Phjsw==",
"dev": true,
"license": "(Apache-2.0 OR MIT)",
"peer": true,
@@ -25215,9 +25206,9 @@
}
},
"node_modules/vis-util": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.3.tgz",
"integrity": "sha512-2ar+MN4JYYfhRSh+sds7o0Sg7/i/mNMVXv32DaMg5I4N1AJjTaHvXqNtHcISXl4WyaVkJ6twiCfqStudO2TYFg==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.2.tgz",
"integrity": "sha512-sYawqWllCqwmTPLurRgPChmfRppbWI/XoBWdGNM6N2s4XuVR8VO8gtz/v9AvrAAOfZxel/U5UBNOwyhFn0qb7A==",
"dev": true,
"license": "(Apache-2.0 OR MIT)",
"peer": true,
+34 -37
View File
@@ -3,44 +3,41 @@
"description": "DataController Client",
"angular-cli": {},
"scripts": {
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
"audit:prod": "npm audit --omit=dev",
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
"prestart": "npm run generate-eula",
"generate-eula": "node ./src/eula.ts",
"license-checker": "node licenseChecker.js",
"prebuild": "node ./src/version.ts && npm run generate-eula && npm run license-checker",
"build": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration production",
"postbuild": "rimraf dist/3rdpartylicenses.txt",
"build-dev": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development",
"build-watch": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng build --configuration development --watch",
"check:archiver": "node scripts/check-archiver.mjs",
"sync": "./node_modules/.bin/watch --wait=3 \"echo Account: ${npm_config_account} && npm run deploy_${npm_config_server} --account=${npm_config_account} && notify-send 'Data Controller' 'App is synced!' ; echo 'App is synced!'\" dist",
"deploy_sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc/dev",
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"viyabuild": "cd build; ./viyabuild.sh",
"lint": "npm run lint:fix",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"watch": "ng test watch=true",
"pree2e": "webdriver-manager update",
"e2e": "protractor protractor.config.js",
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
"cypress": "cypress open",
"cy:run": "cypress run",
"audit:prod": "npm audit --omit=dev",
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
"compodoc:build": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --name 'Data Controller Client'",
"compodoc:serve": "compodoc -s --name 'Data Controller Client'",
"cy:run": "cypress run",
"cypress": "cypress open",
"deploy_sas9": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/opt/sas/sas9/config/Lev1/Web/WebServer/htdocs/${npm_config_account}/dc/dev",
"deploy_sasjs": "rsync -avhe ssh ./dist/* --delete root@${npm_config_account}.4gl.io:/var/www/html/dc/dev",
"deploy_viya": "rsync -avhe ssh ./dist/* --delete ${npm_config_account}@sas.4gl.io:/var/www/html/${npm_config_account}/dc/dev",
"e2e": "protractor protractor.config.js",
"generate-eula": "node ./src/eula.ts",
"license-checker": "node licenseChecker.js",
"lighthouse": "lhci autorun",
"lint": "npm run lint:fix",
"lint:check": "npx prettier --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:check:silent": "npx prettier --log-level silent --check \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix": "npx prettier --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"lint:fix:silent": "npx prettier --log-level silent --write \"{src,test}/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\" \"cypress/e2e/*.cy.ts\"",
"ng": "ng",
"postbuild": "rimraf dist/3rdpartylicenses.txt",
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
"prebuild": "node ./src/version.ts && npm run generate-eula && npm run license-checker",
"pree2e": "webdriver-manager update",
"prestart": "npm run generate-eula",
"sasdocs": "sasjs doc && ./sasjs/utils/deploydocs.sh",
"start": "node --max_old_space_size=4096 node_modules/@angular/cli/bin/ng serve",
"sync": "./node_modules/.bin/watch --wait=3 \"echo Account: ${npm_config_account} && npm run deploy_${npm_config_server} --account=${npm_config_account} && notify-send 'Data Controller' 'App is synced!' ; echo 'App is synced!'\" dist",
"test": "npx ng test",
"test:headless": "npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI",
"test:tz-east": "TZ=Pacific/Auckland npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
"test:tz-west": "TZ=America/New_York npx ng test --no-watch --no-progress --browsers ChromeHeadlessCI --include='**/va-*.spec.ts'",
"viyabuild": "cd build; ./viyabuild.sh",
"watch": "ng test watch=true"
"ng": "ng"
},
"private": true,
"dependencies": {
@@ -59,9 +56,10 @@
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
"@handsontable/angular-wrapper": "18.1.2",
"@sasjs/adapter": "5.1.2",
"@sasjs/utils": "4.3.1",
"@sasjs/utils": "4.0.0",
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
"@types/d3-graphviz": "^2.6.7",
"@types/text-encoding": "0.0.35",
"base64-arraybuffer": "^0.2.0",
"browserify-cipher": "^1.0.1",
"buffer": "^5.4.3",
@@ -86,6 +84,7 @@
"save-svg-as-png": "^1.4.17",
"stream-browserify": "3.0.0",
"stream-http": "3.2.0",
"text-encoding": "^0.7.0",
"tslib": "^2.3.0",
"vm": "^0.1.0",
"webpack": "^5.91.0",
@@ -117,7 +116,6 @@
"@types/randombytes": "^2.0.3",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"archiver": "8.0.0",
"core-js": "^2.5.4",
"cypress": "^15.19.0",
"cypress-file-upload": "^5.0.8",
@@ -151,12 +149,11 @@
"lighthouse": "13.4.0",
"fast-uri": "4.2.3",
"readdir-glob": {
"brace-expansion": "5.0.12",
"minimatch": "11.0.3"
"brace-expansion": "^5.0.9"
},
"exceljs": {
"archiver": "8.0.0",
"unzipper": "0.12.5"
"archiver": "^8.0.0",
"unzipper": "^0.12.5"
}
}
}
-74
View File
@@ -1,74 +0,0 @@
/* Guards the readdir-glob -> minimatch override in client/package.json.
*
* The override forces minimatch 11 where readdir-glob asks for ^10, and
* readdir-glob reaches minimatch on two paths: the directory walk, and the
* pattern/ignore matching behind archiver's glob(). Both are exercised here,
* because a matcher that breaks shows up as files silently skipped rather than
* as an error.
*
* Entries are read from archiver's own 'entry' events, so nothing has to be
* unzipped and the check needs no reader dependency.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { ZipArchive } from 'archiver'
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-arch-'))
const source = path.join(root, 'source')
const cleanup = () => fs.rmSync(root, { recursive: true, force: true })
const fail = (message) => {
console.error(message)
cleanup()
process.exit(1)
}
fs.mkdirSync(source)
fs.writeFileSync(path.join(source, 'a.txt'), 'hello')
fs.mkdirSync(path.join(source, 'sub'))
fs.writeFileSync(path.join(source, 'sub', 'b.txt'), 'world')
fs.writeFileSync(path.join(source, 'sub', 'c.md'), 'ignored by pattern')
let runs = 0
const archiveWith = (configure) =>
new Promise((resolve, reject) => {
const out = path.join(root, `test-${++runs}.zip`)
const sink = fs.createWriteStream(out)
const archive = new ZipArchive({ zlib: { level: 9 } })
const names = []
archive.on('entry', (entry) => names.push(entry.name))
archive.on('error', reject)
sink.on('error', reject)
sink.on('close', () => resolve(names))
archive.pipe(sink)
configure(archive)
archive.finalize()
})
try {
/* the directory walk: every file, nested directories included */
const walked = await archiveWith((archive) => archive.directory(source, false))
const missing = ['a.txt', 'sub/b.txt'].filter((n) => !walked.includes(n))
if (missing.length) {
fail('directory walk missed: ' + missing.join(', ') + ' (got: ' + walked.join(', ') + ')')
}
/* pattern and ignore matching, which is minimatch used directly */
const globbed = await archiveWith((archive) =>
archive.glob('**/*.txt', { cwd: source, ignore: ['sub/**'] })
)
if (!globbed.includes('a.txt')) {
fail('glob did not match a.txt (got: ' + globbed.join(', ') + ')')
}
if (globbed.some((n) => n.startsWith('sub/'))) {
fail('glob ignore did not exclude sub/ (got: ' + globbed.join(', ') + ')')
}
console.log('archiver walk ok: ' + walked.join(', '))
console.log('archiver glob ok: ' + globbed.join(', '))
cleanup()
} catch (err) {
fail('archiver error: ' + err.message)
}
+4 -7
View File
@@ -48,13 +48,10 @@ console.log(
`(${sizeBefore} -> ${sizeAfter} bytes, saved ${sizeBefore - sizeAfter})`
)
// The build cache treats node_modules as immutable, so an in-place edit
// here does not invalidate the entries compiled from this file. Measured
// on the Angular 20 builder: a marker @font-face injected into
// clr-ui.min.css was absent from a warm build's CSS and present in a cold
// one. Drop the cache so the next build re-reads the stripped stylesheet.
// CI restores its cache after this script runs, so the restore is never
// deleted by this clear.
// Webpack 5's persistent cache treats node_modules as immutable
// (snapshot.module.managedPaths default), so in-place edits don't
// invalidate cached entries. Drop the Angular build cache so the next
// build re-reads our stripped clr-ui.min.css.
const cacheDir = resolve('.angular/cache')
if (existsSync(cacheDir)) {
rmSync(cacheDir, { recursive: true, force: true })
-18
View File
@@ -1,5 +1,4 @@
import { QueryClause } from './models/TableData'
import { VaParmapTarget } from './shared/utils/parse-va-parmap'
/**
* Filtering cache info, to be reused when filtering modal is re-open
@@ -62,21 +61,6 @@ export const globals: {
* Defaults to 'live'; toggled by the Auto-apply checkbox in the editor.
*/
vaApplyMode: 'live' | 'confirm'
/**
* Explicit VA parameter -> DC column mappings, parsed from the `va_parmap`
* URL parameter (see parseVaParmap). Empty unless configured; entries override
* VA's default same-name (parameter label -> column) resolution, so two DC
* instances over one table can filter the same column from differently-named
* VA parameters.
*/
vaParamMap: Map<string, VaParmapTarget>
/**
* `va_parmap` entries that could not be used at all (not a plain SAS column
* name, or missing a side of the `-`). Surfaced to the report author, because
* these are hand-written in the report's URL and a typo otherwise looks like
* a working, unfiltered view.
*/
vaParmapProblems: string[]
rootParam: string
dcLib: string
xlmaps: XLMapListItem[]
@@ -93,8 +77,6 @@ export const globals: {
} = {
embed: false,
vaApplyMode: 'live',
vaParamMap: new Map(),
vaParmapProblems: [],
rootParam: <string>'',
dcLib: '',
xlmaps: [],
+102 -12
View File
@@ -1,16 +1,106 @@
<div class="main-container">
@if (!router.url.includes('licensing')) {
<app-licence-banner
[startupDataLoaded]="startupDataLoaded"
[appActive]="appActive"
[freeTier]="freeTierBanner"
[expiringDays]="licenseExpiringDays"
[overCapacity]="appOverCapacity"
[licenceProblem]="licenceProblem.value"
[canRegister]="currentUserCanRegister"
[siteId]="siteId"
(problemDetails)="licenceProblemDetails($event)"
/>
@if (freeTierBanner && (!licenseExpiringDays || licenseExpiringDays < 0)) {
<div
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
@if (licenceProblem.value === null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - to upgrade contact
<contact-link classes="color-white" />
</div>
</div>
</div>
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
@if (licenceProblem.value !== null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon
class="alert-icon"
shape="warning-standard"
></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="licenceProblemDetails(licenceProblem.value)"
class="update-key cursor-pointer"
>More details</a
>
}
</div>
}
@if (licenseExpiringDays && !freeTierBanner) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
This license key will expire in {{ licenseExpiringDays }}
{{ licenseExpiringDays === 1 ? 'day' : 'days' }}. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
@if (appOverCapacity) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
The registered number of users exceeds the limit specified for
your license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ syssite.getValue() }}.
</div>
</div>
</div>
@if (!licenseExpiringDays && !freeTierBanner) {
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
</div>
}
}
@if (!embed) {
@@ -145,7 +235,7 @@
@if (!errTop) {
<app-alerts></app-alerts>
}
<app-sas-logs-modal [(opened)]="sasLogsModal"></app-sas-logs-modal>
<app-requests-modal [(opened)]="requestsModal"></app-requests-modal>
<app-excel-password-modal></app-excel-password-modal>
<!-- <app-terms *ngIf="showRegistration"></app-terms> -->
+8 -39
View File
@@ -12,7 +12,6 @@ import '@clr/icons'
import '@clr/icons/shapes/all-shapes'
import { globals } from './_globals'
import { parseEmbedParam } from './shared/utils/parse-embed-param'
import { parseVaParmap } from './shared/utils/parse-va-parmap'
import moment from 'moment'
import { EventService } from './services/event.service'
import { AppService } from './services/app.service'
@@ -67,14 +66,8 @@ export class AppComponent {
public freeTierBanner: boolean = this.licenceService.isAppFreeTier.value
public licenceProblem = this.licenceService.licenceProblem
public appOverCapacity: boolean = false
public currentUserCanRegister: boolean = true
/** Site id(s) from the startupservice, as shown in the licence banner. */
public get siteId(): string {
return (this.syssite.getValue() || []).join(', ')
}
public appActive: boolean | null = null
public sasLogsModal: boolean = false
public requestsModal: boolean = false
public showRegistration: boolean = true
public startupDataLoaded: boolean = false
public startupSteps: StartupStep[] = []
@@ -170,24 +163,9 @@ export class AppComponent {
globals.embed = embed
this.embed = embed
}
// VA parameter -> DC column mappings for this instance, held on globals
// (like `embed` and `vaApplyMode`) so in-app navigation that rebuilds the
// editor keeps them - e.g. returning from the stage / approve pages.
if (globals.embed === 'va') {
const parmap = parseVaParmap(hashQuery ?? '')
globals.vaParamMap = parmap.map
globals.vaParmapProblems = parmap.problems
// Visible in the browser console as well as the editor banner: the entry
// is hand-written in the report's URL, so a typo has to be findable when
// someone is looking at the report rather than at DC's UI.
for (const problem of parmap.problems) {
// eslint-disable-next-line no-console
console.warn('Data Controller: ' + problem)
}
}
this.subscribeToShowAbortModal()
this.subscribeToSasLogsModal()
this.subscribeToRequestsModal()
this.subscribeToStartupData()
this.subscribeToAppActive()
this.subscribeToDemoLimitModal()
@@ -316,12 +294,6 @@ export class AppComponent {
this.appOverCapacity = isAppOverAppCapacity
}
)
this.licenceService.currentUserCanRegister.subscribe(
(canRegister: boolean) => {
this.currentUserCanRegister = canRegister
}
)
}
/**
@@ -342,12 +314,9 @@ export class AppComponent {
*/
public subscribeToShowAbortModal() {
this.eventService.onShowAbortModal.subscribe((sasjsAbort: InfoModal) => {
/* One message at a time. Pushing to the array stacked a second modal on
top of the one already open - two dismissals for one failure, and the
lower one was often a duplicate of the upper. Replacing keeps the
newest message, which is the one the user has to act on. */
sasjsAbort.id = Date.now()
this.sasjsAborts = [sasjsAbort]
let abortId = this.sasjsAborts.length + 1
sasjsAbort.id = abortId
this.sasjsAborts.push(sasjsAbort)
this.cdr.detectChanges() //Changes were not triggered while hot is focused
})
}
@@ -365,9 +334,9 @@ export class AppComponent {
/**
* Opens requests modal when requested from event service
*/
public subscribeToSasLogsModal() {
this.eventService.onSasLogsModalOpen.subscribe((value: boolean) => {
this.sasLogsModal = true
public subscribeToRequestsModal() {
this.eventService.onRequestsModalOpen.subscribe((value: boolean) => {
this.requestsModal = true
})
}
+4 -4
View File
@@ -6,7 +6,6 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { ClarityModule } from '@clr/angular'
import { AppComponent } from './app.component'
import { LicenceBannerComponent } from './licence-banner/licence-banner.component'
import { ROUTING } from './app.routing'
import { NotFoundComponent } from './not-found/not-found.component'
@@ -16,10 +15,11 @@ import { SharedModule } from './shared/shared.module'
import { AppSharedModule } from './app-shared.module'
import { PipesModule } from './pipes/pipes.module'
import { ReviewRouteComponent } from './routes/review-route/review-route.component'
import { LicensingGuard } from './routes/licensing.guard'
import { UsernavRouteComponent } from './routes/usernav-route/usernav-route.component'
import { AppService } from './services/app.service'
import { InfoModalComponent } from './shared/abort-modal/info-modal.component'
import { SasLogsModalComponent } from './shared/sas-logs-modal/sas-logs-modal.component'
import { RequestsModalComponent } from './shared/requests-modal/requests-modal.component'
import { DirectivesModule } from './directives/directives.module'
import { ViyaApiExplorerComponent } from './viya-api-explorer/viya-api-explorer.component'
import { NgxJsonViewerModule } from 'ngx-json-viewer'
@@ -28,12 +28,11 @@ import { AppSettingsService } from './services/app-settings.service'
@NgModule({
declarations: [
AppComponent,
LicenceBannerComponent,
NotFoundComponent,
ReviewRouteComponent,
ReviewRouteComponent,
UsernavRouteComponent,
SasLogsModalComponent,
RequestsModalComponent,
InfoModalComponent,
ViyaApiExplorerComponent
],
@@ -54,6 +53,7 @@ import { AppSettingsService } from './services/app-settings.service'
providers: [
AppService,
SasStoreService,
LicensingGuard,
AppSettingsService,
provideHttpClient(withInterceptorsFromDi())
]
+2 -31
View File
@@ -1,36 +1,7 @@
<main class="content-area position-relative">
<!-- An embedded instance (a report object) has no report author who can deploy:
showing the wizard there only produces a screen whose buttons cannot work,
because the deployment needs an authenticated, non-embedded session. Name
the state instead, and keep the wizard for standalone use. -->
@if (isEmbedded) {
<div class="clr-row">
<div id="not-configured" class="card">
<div class="card-header">Data Controller is not configured</div>
<div class="card-block">
<div class="card-text">
<p class="mt-0">
This Data Controller instance has not been deployed yet, so it
cannot be shown inside a report.
</p>
<p>
Deployment has to be done once, in its own browser tab, by someone
who can write to the application location:
<a [href]="deployUrl" target="_blank">{{ deployUrl }}</a>
</p>
<p>
When it is configured, reload the report and this object will show
the editor.
</p>
</div>
</div>
</div>
</div>
}
<div class="clr-row">
<!-- T&C section -->
@if (!isEmbedded && step === 0) {
@if (step === 0) {
<div id="TCS" class="card">
<div class="card-header">Terms and Conditions</div>
<div class="card-block">
@@ -64,7 +35,7 @@
</div>
<!-- T&C section end -->
@if (!isEmbedded && step > 0) {
@if (step > 0) {
@switch (true) {
@case (sasJsConfig.serverType === ServerType.SasViya) {
<div class="autodeploy-section card">
@@ -1,229 +0,0 @@
import { NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { ServerType } from '@sasjs/utils/types/serverType'
import { DeployComponent } from './deploy.component'
import { standaloneUrl } from './standalone-url'
import { globals } from '../_globals'
import { AppStoreService } from '../services/app-store.service'
import { SasService } from '../services/sas.service'
/**
* An embedded instance cannot deploy itself: the deployment needs an
* authenticated, standalone session, and the person looking at a report object
* is not the person who installs the app. The component therefore withholds the
* wizard and names the state instead.
*
* These cases render the template, so they fail if the `!isEmbedded` guards in
* deploy.component.html are dropped - asserting the getter alone would not.
*/
describe('DeployComponent embed guard', () => {
let fixture: ComponentFixture<DeployComponent>
let component: DeployComponent
const originalEmbed = globals.embed
const configure = () =>
TestBed.configureTestingModule({
declarations: [DeployComponent],
providers: [
{
provide: AppStoreService,
useValue: {
getDcAdapterSettings: () => ({
appLoc: '/Public/app/dc',
dcPath: '/tmp/dc'
})
}
},
{
provide: SasService,
useValue: {
getSasjsInstance: () => ({}),
getSasjsConfig: () => ({ serverType: ServerType.Sasjs })
}
}
],
// The wizard renders child components this spec does not exercise.
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents()
const render = () => {
fixture = TestBed.createComponent(DeployComponent)
component = fixture.componentInstance
fixture.detectChanges()
}
const tcsSection = () =>
fixture.nativeElement.querySelector('#TCS') as HTMLElement | null
const notConfigured = () =>
fixture.nativeElement.querySelector('#not-configured') as HTMLElement | null
const deploySection = () =>
fixture.nativeElement.querySelector(
'.autodeploy-section'
) as HTMLElement | null
afterEach(() => {
globals.embed = originalEmbed
TestBed.resetTestingModule()
})
it('offers the wizard when running in its own tab', () => {
globals.embed = false
configure()
render()
expect(tcsSection()).toBeTruthy()
expect(notConfigured()).toBeNull()
expect(component.isEmbedded).toBeFalse()
})
it('replaces the wizard with the notice for a VA embed', () => {
globals.embed = 'va'
configure()
render()
expect(tcsSection()).toBeNull()
expect(notConfigured()).toBeTruthy()
})
it('replaces the wizard with the notice for a plain embed too', () => {
globals.embed = true
configure()
render()
expect(tcsSection()).toBeNull()
expect(notConfigured()).toBeTruthy()
})
it('names the standalone URL in the embedded notice', () => {
globals.embed = 'va'
configure()
render()
const link = notConfigured()!.querySelector('a') as HTMLAnchorElement
expect(link.getAttribute('href')).toBe(component.deployUrl)
expect(link.textContent).toBe(component.deployUrl)
})
it('withholds the deploy section after the terms are accepted, when embedded', () => {
globals.embed = 'va'
configure()
render()
component.step = 1
fixture.detectChanges()
expect(deploySection()).toBeNull()
})
it('shows the deploy section after the terms are accepted, standalone', () => {
globals.embed = false
configure()
render()
component.step = 1
fixture.detectChanges()
expect(deploySection()).toBeTruthy()
})
})
describe('DeployComponent onNavigateToHome', () => {
const originalEmbed = globals.embed
const originalHref = window.location.href
afterEach(() => {
globals.embed = originalEmbed
history.replaceState(null, '', originalHref)
TestBed.resetTestingModule()
})
it('opens the standalone URL, merging an embed tab into the app URL', () => {
// Karma serves the runner with its own query, so the app URL is set here
// rather than read from window.location: the assertion has to see the
// app's query survive the embed parameters being dropped. The shape is
// the real SAS 9 one - the app's `_program` query in the search part,
// the embed switches in the hash query.
history.replaceState(
null,
'',
'/deploy-tc?_program=/Public/app/dc&_debug=131#/editor?embed=va&va_parmap=A-B'
)
const open = spyOn(window, 'open')
TestBed.configureTestingModule({
declarations: [DeployComponent],
providers: [
{
provide: AppStoreService,
useValue: { getDcAdapterSettings: () => ({ appLoc: '', dcPath: '' }) }
},
{
provide: SasService,
useValue: {
getSasjsInstance: () => ({}),
getSasjsConfig: () => ({ serverType: ServerType.Sasjs })
}
}
],
schemas: [NO_ERRORS_SCHEMA]
})
const fixture = TestBed.createComponent(DeployComponent)
fixture.componentInstance.onNavigateToHome()
const opened = open.calls.mostRecent().args[0] as string
expect(opened).toBe(
window.location.origin + '/deploy-tc?_program=/Public/app/dc&_debug=131'
)
expect(opened).not.toContain('#')
expect(opened).not.toContain('embed=')
})
})
describe('standaloneUrl', () => {
const origin = window.location.origin
it('keeps the path of a plain URL', () => {
expect(standaloneUrl(`${origin}/SASJobExecution/dc`)).toBe(
`${origin}/SASJobExecution/dc`
)
})
it('keeps a query the app needs, and drops the hash route', () => {
expect(standaloneUrl(`${origin}/path?x=1#/deploy`)).toBe(
`${origin}/path?x=1`
)
})
it('keeps `_program=`, which is how SAS 9 and Viya address the app', () => {
// The whole app URL is the query here: /SASJobExecution/?_program=... is
// the standalone app, so dropping the query would open a blank executor.
const sas9 =
`${origin}/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice` +
'&_debug=131'
expect(standaloneUrl(sas9)).toBe(sas9)
})
it('drops a bare embed query', () => {
expect(standaloneUrl(`${origin}/app?embed=va`)).toBe(`${origin}/app`)
})
it('drops the va_parmap entries along with the embed switch', () => {
expect(
standaloneUrl(
`${origin}/app?embed=va&va_parmap=A-B&va_parmap=C-D#/editor/x`
)
).toBe(`${origin}/app`)
})
it('keeps a query parameter that merely starts with va_', () => {
expect(standaloneUrl(`${origin}/app?embed=va&va_rowlimit=100`)).toBe(
`${origin}/app?va_rowlimit=100`
)
})
it('resolves a relative URL against the current origin', () => {
expect(standaloneUrl('/app/#/deploy')).toBe(`${origin}/app/`)
})
})
+1 -19
View File
@@ -4,8 +4,6 @@ import { SASjsConfig } from '@sasjs/adapter'
import { ServerType } from '@sasjs/utils/types/serverType'
import { AppStoreService } from '../services/app-store.service'
import { DcAdapterSettings } from '../models/DcAdapterSettings'
import { globals } from '../_globals'
import { standaloneUrl } from './standalone-url'
@Component({
selector: 'app-deploy',
@@ -46,20 +44,6 @@ export class DeployComponent implements OnInit {
this.setDeployDefaults()
}
/**
* True when DC is running inside a report rather than in its own tab. The
* deployment needs an authenticated, standalone session, so the wizard is not
* offered here - a report object cannot deploy the app that it is displaying.
*/
get isEmbedded(): boolean {
return !!globals.embed
}
/** The standalone URL an administrator deploys from, for the embedded notice. */
get deployUrl(): string {
return standaloneUrl(window.location.href)
}
/**
* Setting default values used for deploy request
*/
@@ -76,8 +60,6 @@ export class DeployComponent implements OnInit {
}
public onNavigateToHome() {
// Same helper as deployUrl: splitting on '#' left the query string in
// place, so a tab opened from `?embed=va#/deploy` stayed embedded.
window.open(standaloneUrl(window.location.href), '_blank')
window.open(location.href.split('#')[0], '_blank')
}
}
+1 -3
View File
@@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common'
import { DeployComponent } from './deploy.component'
import { AutomaticComponent } from './sections/automatic/automatic.component'
import { SasjsConfiguratorComponent } from './sections/sasjs-configurator/sasjs-configurator.component'
import { DcLocationComponent } from './sections/dc-location/dc-location.component'
import { ClarityModule } from '@clr/angular'
import { FormsModule } from '@angular/forms'
import { DeployRoutingModule } from './deploy-routing.module'
@@ -12,8 +11,7 @@ import { DeployRoutingModule } from './deploy-routing.module'
declarations: [
DeployComponent,
AutomaticComponent,
SasjsConfiguratorComponent,
DcLocationComponent
SasjsConfiguratorComponent
],
imports: [CommonModule, FormsModule, ClarityModule, DeployRoutingModule]
})
@@ -8,6 +8,20 @@
@if (autodeployDone) {
<p class="m-0 align-self-start">Done</p>
<hr class="w-100" />
@if (autoDeployStatus.deployServicePack !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.deployServicePack === true) {
<clr-icon
class="deploy-success"
shape="success-standard"
></clr-icon>
}
@if (!autoDeployStatus.deployServicePack === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Deploy SAS Jobs</p>
</div>
}
@if (autoDeployStatus.runMakeData !== null) {
<div class="deploy-status-row">
@if (autoDeployStatus.runMakeData) {
@@ -19,13 +33,16 @@
@if (autoDeployStatus.runMakeData === false) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
<p>Create control library</p>
<p>Create database</p>
</div>
}
<hr class="w-100" />
<div class="buttons">
<button (click)="navigateToHome()" class="btn btn-primary mt-15 mr-0">
@if (autoDeployStatus.runMakeData === false) {
@if (
autoDeployStatus.deployServicePack === false ||
autoDeployStatus.runMakeData === false
) {
<clr-icon class="deploy-error" shape="times-circle"></clr-icon>
}
LAUNCH
@@ -48,10 +65,10 @@
Close
</button>
<button
(click)="openSasLogsModal()"
(click)="openSasRequestsModal()"
class="btn btn-primary-outline mt-15 mr-0 align-self-end"
>
SAS Logs
SAS Requests
</button>
</div>
}
@@ -61,9 +78,9 @@
<h4 class="text-center my-15">Viya Deploy</h4>
<p class="deploy-intro">
Configure Data Controller for your Viya environment. The frontend and services
are already in place; this runs the one-off job that creates the control
library and its configuration data, then removes itself.
Configure and deploy Data Controller to your Viya environment. Deployment
creates the backend SAS services and the control library used by the
application.
</p>
<hr />
@@ -81,6 +98,20 @@
</div>
</div>
<div class="deploy-field">
<label for="dcloc" class="clr-control-label">DC Loc</label>
<p class="deploy-field-description">
A physical directory on the compute server to which the deployment user can
write. Data Controller stores its control tables, staging area and audit
history here.
</p>
<div class="clr-control-container dc-loc-input-wrapper">
<div class="clr-input-wrapper small-mt">
<input clrInput name="dcloc" [(ngModel)]="dcPath" />
</div>
</div>
</div>
<div class="deploy-field">
<label for="adminGroup" class="clr-control-label">SAS Admin group</label>
<p class="deploy-field-description">
@@ -205,7 +236,7 @@
<label class="clr-control-label">Running as user</label>
<p class="deploy-field-description">
The identity under which Data Controller jobs will run with the selected
compute context. This user must be able to write to the DC Loc below.
compute context. This user must be able to write to the DC Loc above.
</p>
<div class="clr-control-container">
<div class="clr-input-wrapper">
@@ -217,30 +248,19 @@
</div>
}
<!--
DC Loc comes last, after the compute context: the folder listing and the
write check run inside the chosen context, as its batch identity, so the
context has to be selected before the directory can be probed.
-->
<div class="deploy-field">
<label for="dcloc" class="clr-control-label">DC Loc</label>
<p class="deploy-field-description">
A physical directory on the compute server to which
<strong>{{ runningAsUser || 'the deployment user' }}</strong> can write.
Data Controller stores its control tables, staging area and audit history
here.
</p>
<div class="clr-control-container dc-loc-input-wrapper">
<app-dc-location
[(path)]="dcPath"
inputId="dcloc"
[computeContextName]="selectedComputeContextName()"
></app-dc-location>
</div>
</div>
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <clr-checkbox-wrapper>
<input
clrCheckbox
[(ngModel)]="recreateDatabase"
(click)="recreateDatabaseClicked($event)"
type="checkbox"
checked
/>
<label>Recreate database</label>
</clr-checkbox-wrapper> -->
<!-- Runs the one-off makedata job. This is the screen's only action: the
frontend and services are already deployed. -->
<hr />
<button
@@ -249,3 +269,53 @@
>
Deploy
</button>
<!-- Keeping this for a reference in case future VIYA changes and starts allowing separate backend and frontend) -->
<!-- <button
(click)="executeJson()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
[disabled]="!jsonFile"
>
Deploy {{ !jsonFile ? '(json file is not available)' : '' }}
</button> -->
<!-- <button
(click)="uploadJsonAuto.click()"
class="btn-autodeploy btn btn-primary d-inline-block mr-10"
>
Upload different file to deploy
</button>
<input
#uploadJsonAuto
type="file"
hidden
(click)="clearUploadInput($event)"
(change)="onJsonFileChange($event)"
/> -->
<clr-modal [(clrModalOpen)]="recreateDatabaseModal" [clrModalClosable]="false">
<h3 class="modal-title">Warning</h3>
<div class="modal-body">
This action will recreate the database (if it exists). For an initial
deployment, this is expected. If this is a re-deployment, you will lose any
existing tables in <strong>{{ dcPath }}</strong
>.
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-sm btn-primary"
(click)="recreateDatabaseModal = false; recreateDatabase = false"
>
Cancel
</button>
<button
type="button"
class="btn btn-sm btn-primary"
(click)="recreateDatabaseModal = false; recreateDatabase = true"
>
Ok
</button>
</div>
</clr-modal>
@@ -1,325 +0,0 @@
import { Component, NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { SASjsConfig } from '@sasjs/adapter'
import { DeployService } from 'src/app/services/deploy.service'
import { EventService } from 'src/app/services/event.service'
import { HelperService } from 'src/app/services/helper.service'
import { LoggerService } from 'src/app/services/logger.service'
import { SasService } from 'src/app/services/sas.service'
import { SasViyaService } from 'src/app/services/sas-viya.service'
import { DcLocationComponent } from '../dc-location/dc-location.component'
import { AutomaticComponent } from './automatic.component'
/**
* The Viya setup screen has one job: run `makedata` with the selections the
* form collected - the admin group, the DC path, and the compute context the
* job runs in. These tests pin that payload and the new-window URL, because a
* screen whose only action silently stopped calling the service would still
* compile, still render, and still look like it worked.
*/
describe('AutomaticComponent (Viya setup)', () => {
const build = () => {
const requests: any[][] = []
const sasJs: any = {
request: (...args: any[]) => {
requests.push(args)
return Promise.resolve({ result: [{}] })
}
}
const sasService: any = {
getSasjsConfig: () => new SASjsConfig(),
getExecutionPath: () => '/SASJobExecution/',
shouldLogin: { next: () => undefined },
viyaMakedataSuccessfull: () => Promise.resolve(true),
updateFileContent: () => Promise.resolve()
}
const component = new AutomaticComponent(
{
showAbortModal: () => undefined,
showInfoModal: () => undefined
} as any,
{ downloadFile: () => undefined } as any,
sasService,
{} as any,
{ error: () => undefined } as any,
{} as any
)
component.sasJs = sasJs
const config = {
contextName: 'Compute Reusable',
serverUrl: 'https://viya.example.com',
appLoc: '/Users/demo/dc'
} as any
component.sasJsConfig = config
// The new-window URL is built from the adapter config the component holds,
// while the request path reads the @Input one.
component.sasjsConfig = config
component.appLoc = '/Users/demo/dc'
component.dcPath = '/export/dc'
component.selectedAdminGroup = 'viyagroup18'
return { component, requests, sasService }
}
it('posts makedata with the admin group and the DC path', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
await component.createDatabase()
expect(requests[0][0]).toBe('services/admin/makedata')
expect(requests[0][1]).toEqual({
fromjs: [
{ ACTION: 'MAKEDATA', ADMIN: 'viyagroup18', DCPATH: '/export/dc' }
]
})
})
it('runs the job in the configured context, on the web method', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
await component.createDatabase()
expect(requests[0][2]).toEqual({
useComputeApi: null,
contextName: 'Compute Reusable',
debug: true
})
})
it('prefers the context chosen in the form over the configured one', async () => {
const { component, requests } = build()
component.deployInNewWindow = false
component.computeContexts = [
{ id: 'ctx-1', name: 'Selected Context' } as any
]
component.selectedComputeContext = 'ctx-1'
await component.createDatabase()
expect(requests[0][2].contextName).toBe('Selected Context')
})
it('names the chosen context for the directory probes', () => {
// The folder listing and the write check run in the deployment's context,
// so the picker is handed the same context the makedata job will use.
const { component } = build()
component.computeContexts = [
{ id: 'ctx-1', name: 'Selected Context' } as any
]
component.selectedComputeContext = 'ctx-1'
expect(component.selectedComputeContextName()).toBe('Selected Context')
})
it('falls back to the configured context until one is chosen', () => {
const { component } = build()
expect(component.selectedComputeContextName()).toBe('Compute Reusable')
})
it('opens makedata in a new window with the selections as URL parameters', () => {
const { component } = build()
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
spyOn(component as any, 'pollMakedataCompletion')
component.deployInNewWindow = true
component.createDatabase()
expect(opened.length).toBe(1)
expect(opened[0]).toContain('/Users/demo/dc/services/admin/makedata')
expect(opened[0]).toContain('_contextname=Compute%20Reusable')
expect(opened[0]).toContain('admin=viyagroup18')
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc')
expect(opened[0]).toContain('action=MAKEDATA')
expect(opened[0]).toContain('_debug=131')
})
it('percent-encodes the values that need it in the new-window URL', () => {
// A context name or admin group can hold characters that would otherwise
// split the query string - an ampersand would start a new parameter and a
// space would arrive truncated.
const { component } = build()
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
// The real poll loads the page once it sees makedata finish: left running
// here it fires five seconds later, during whichever spec is running then,
// and Karma aborts with "Some of your tests did a full page reload".
spyOn(component as any, 'pollMakedataCompletion')
component.runMakedataInNewWindow({
contextName: 'SAS Job Execution & co',
admin: 'group 1+2',
dcPath: '/export/dc data'
})
expect(opened[0]).toContain('_contextname=SAS%20Job%20Execution%20%26%20co')
expect(opened[0]).toContain('admin=group%201%2B2')
expect(opened[0]).toContain('dcpath=%2Fexport%2Fdc%20data')
// the parameter names themselves are not encoded
expect(opened[0]).toContain('&admin=group%201%2B2')
})
it('stops the makedata poll when the screen is destroyed', () => {
// The poll runs every five seconds for up to ten minutes and reloads the
// page the first time it sees makedata gone. It must not outlive the
// screen: a timer left behind reloads whatever the user moved on to.
const { component } = build()
spyOn(window, 'open')
component.runMakedataInNewWindow({
contextName: 'Compute Reusable',
admin: 'viyagroup18',
dcPath: '/export/dc'
})
expect((component as any).makedataPollInterval).not.toBeNull()
component.ngOnDestroy()
expect((component as any).makedataPollInterval).toBeNull()
})
it('starts the job when deploy is pressed', () => {
// The screen's only button calls this; a version where it stopped doing so
// would leave the setup page unable to do anything at all.
const { component } = build()
const spy = spyOn(component, 'createDatabase').and.returnValue(
Promise.resolve() as any
)
component.runAutoDeploy()
expect(spy).toHaveBeenCalled()
})
})
/**
* The order the fields appear in, rendered. The directory probes (folder
* listing, write check) run inside the compute context chosen on this screen,
* as its batch identity - so the context has to be picked before the directory
* is probed, and the template has to ask for them in that order. A template
* edit that silently moved DC Loc back above the context would compile, render
* and deploy fine, and offer a directory listing from the wrong identity.
*/
describe('AutomaticComponent (Viya setup) field order', () => {
@Component({
selector: 'app-automatic-deploy-host',
template:
'<app-automatic-deploy [sasJsConfig]="config"></app-automatic-deploy>',
standalone: false
})
class HostComponent {
config = new SASjsConfig()
}
let fixture: ComponentFixture<HostComponent>
const viyaService = () => ({
getComputeContexts: () => ({
subscribe: (next: any) => next({ items: [] })
}),
getComputeContextById: () => ({ subscribe: (next: any) => next({}) }),
getCurrentUser: () => ({
subscribe: (next: any) => next({ id: 'demo' })
}),
getAdminGroups: () => ({ subscribe: (next: any) => next({ items: [] }) }),
getCurrentUserGroupMemberships: () => ({
subscribe: (next: any) => next({ items: [] })
})
})
beforeEach(() => {
const sasService: any = {
getSasjsConfig: () => new SASjsConfig(),
getExecutionPath: () => '/SASJobExecution/',
shouldLogin: { next: () => undefined },
viyaMakedataSuccessfull: () => Promise.resolve(true),
updateFileContent: () => Promise.resolve(),
getServerType: () => 'SASVIYA'
}
TestBed.configureTestingModule({
declarations: [AutomaticComponent, HostComponent, DcLocationComponent],
imports: [FormsModule],
providers: [
{ provide: SasViyaService, useValue: viyaService() },
{ provide: SasService, useValue: sasService },
{ provide: HelperService, useValue: { isStreamingViya: () => false } },
{ provide: EventService, useValue: {} },
{ provide: DeployService, useValue: { downloadFile: () => undefined } },
{ provide: LoggerService, useValue: { error: () => undefined } }
],
schemas: [NO_ERRORS_SCHEMA]
})
fixture = TestBed.createComponent(HostComponent)
fixture.detectChanges()
})
afterEach(() => {
TestBed.resetTestingModule()
})
it('asks for the admin group and the compute context before the directory', async () => {
await fixture.whenStable()
fixture.detectChanges()
const labels = Array.from(
fixture.nativeElement.querySelectorAll('.clr-control-label')
).map((label: unknown) => ((label as HTMLElement).textContent || '').trim())
const indexOf = (text: string) =>
labels.findIndex((label) => label.toLowerCase().includes(text))
const admin = indexOf('admin group')
const context = indexOf('compute context')
const dcLoc = indexOf('dc loc')
expect(admin).toBeGreaterThanOrEqual(0)
expect(context).toBeGreaterThanOrEqual(0)
expect(dcLoc).toBeGreaterThanOrEqual(0)
expect(admin).toBeLessThan(context)
expect(context).toBeLessThan(dcLoc)
})
it('hands the picker the context selected on this screen', async () => {
await fixture.whenStable()
fixture.detectChanges()
const automatic = fixture.debugElement.query(
By.directive(AutomaticComponent)
).componentInstance as AutomaticComponent
const picker = () =>
fixture.debugElement.query(By.directive(DcLocationComponent))
.componentInstance as DcLocationComponent
// Until the form has a selection the picker is offered the adapter's
// configured context, which is what the probes fall back to.
expect(picker().computeContextName).toBe(automatic.sasJsConfig.contextName)
// Selecting a context on this screen has to reach the picker: the probes
// run in that context, as its batch identity.
automatic.computeContexts = [
{ id: 'ctx-1', name: 'Compute Reusable' } as any
]
automatic.selectedComputeContext = 'ctx-1'
fixture.detectChanges()
expect(picker().computeContextName).toBe('Compute Reusable')
})
})
@@ -2,7 +2,6 @@ import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewEncapsulation
@@ -40,7 +39,7 @@ export interface ComputeContextInfo {
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class AutomaticComponent implements OnInit, OnDestroy {
export class AutomaticComponent implements OnInit {
@Input() sasJs!: SASjs
@Input() sasJsConfig: SASjsConfig = new SASjsConfig()
@Input() dcAdapterSettings: DcAdapterSettings | undefined
@@ -52,8 +51,17 @@ export class AutomaticComponent implements OnInit, OnDestroy {
public selectedComputeContext: string = ''
public makeDataResponse: string = ''
public jsonFile: any = null
public autodeploying: boolean = false
public autodeployDone: boolean = false
public recreateDatabaseModal: boolean = false
public isSubmittingJson: boolean = false
public isJsonSubmitted: boolean = false
/**
* Default was `false` when deploy was done with frontend and backend separately.
* Now we are using only streaming app, so we always want to recreate database (makedata)
*/
public recreateDatabase: boolean = true
public createDatabaseLoading: boolean = false
public adminGroupsLoading: boolean = false
public currentUserInfoLoading: boolean = false
@@ -78,14 +86,18 @@ export class AutomaticComponent implements OnInit, OnDestroy {
public currentUserInfo: ViyaApiCurrentUser | null = null
public computeContexts: ComputeContextItem[] = []
/**
* Status of the one step this screen runs: `makedata`, which creates the
* control library and its configuration data and then deletes itself.
* `null` means it has not run yet, `false` that it failed.
/** autoDeployStatus
* This object presents the status for two steps that we have for deploy.
* `deployServicePack` - Creating services based on `viya.json`
* `runMakeData` - Running `makedata` service
* If any of them is `null` or `false` that means step failed
* and will be shown to user on deploy done modal.
*/
public autoDeployStatus: {
deployServicePack: any
runMakeData: any
} = {
deployServicePack: null,
runMakeData: null
}
@@ -408,28 +420,61 @@ export class AutomaticComponent implements OnInit, OnDestroy {
}
/**
* The name of the context the makedata job - and the directory probes it
* depends on - will run in. Empty until a context is selected, so the
* picker falls back to the adapter's configured context.
* Executes sas.json file to deploy the backend
* Method will first try to run the `auto deploy`
* If that fails the rest of the code is ignored.
* If request is successfull, method will continue to try
* to create database if checkbox is toggled on
*/
public selectedComputeContextName(): string {
return (
this.getComputeContextName(this.selectedComputeContext) ||
this.sasJsConfig.contextName ||
''
)
public async executeJson() {
this.isSubmittingJson = true
try {
let uploadJsonFile = await this.sasJs.deployServicePack(
this.jsonFile,
this.dcAdapterSettings?.appLoc,
undefined,
undefined,
true
)
this.autoDeployStatus.deployServicePack = true
this.isJsonSubmitted = true
} catch (ex: any) {
let textEx = ''
if (typeof ex.message !== 'string') {
textEx = JSON.stringify(ex).replace(/\\/gm, '')
} else {
textEx = ex.message
}
this.autoDeployStatus.deployServicePack = false
this.eventService.showInfoModal(
'Deploy error',
`Exception: \n ${textEx !== '' ? textEx : ex}`
)
this.autodeploying = false
this.autodeployDone = false
return
}
this.isSubmittingJson = false
}
/**
* Runs the deployment. The control library and its configuration data are
* created by `makedata`, which self-deletes once it has run; the frontend and
* services arrived with the sasjs deploy, so there is nothing else to deploy
* from here.
*/
public runAutoDeploy() {
public async runAutoDeploy(executeJson: boolean = false) {
if (!this.deployInNewWindow) this.autodeploying = true
this.createDatabase()
if (executeJson) {
this.executeJson()
}
if (this.recreateDatabase) {
this.createDatabase()
} else {
if (!this.deployInNewWindow) this.autodeployDone = true
}
}
/**
@@ -439,7 +484,6 @@ export class AutomaticComponent implements OnInit, OnDestroy {
let data = {
fromjs: [
{
ACTION: 'MAKEDATA',
ADMIN: this.selectedAdminGroup,
DCPATH: this.dcPath
}
@@ -534,11 +578,9 @@ export class AutomaticComponent implements OnInit, OnDestroy {
let serverUrl = this.sasjsConfig.serverUrl
let appLoc = this.sasjsConfig.appLoc
const execPath = this.sasService.getExecutionPath()
let contextname = `&_contextname=${encodeURIComponent(params.contextName)}`
let admin = `&admin=${encodeURIComponent(params.admin)}`
let dcPath = `&dcpath=${encodeURIComponent(params.dcPath)}`
// makedata requires ACTION; MAKEDATA is the seeding run
let action = '&action=MAKEDATA'
let contextname = `&_contextname=${params.contextName}`
let admin = `&admin=${params.admin}`
let dcPath = `&dcpath=${params.dcPath}`
// Debug is ALWAYS enabled for makedata — it runs only once during
// deployment and the full log is needed to diagnose any issues.
let debug = '&_debug=131'
@@ -552,7 +594,6 @@ export class AutomaticComponent implements OnInit, OnDestroy {
contextname +
admin +
dcPath +
action +
debug
window.open(programUrl)
@@ -565,25 +606,10 @@ export class AutomaticComponent implements OnInit, OnDestroy {
private makedataPollInterval: any = null
/**
* The poll outlives the deploy screen if the user navigates away, and it
* loads the page again the moment it sees makedata finish. Stop it with the
* component, so a timer cannot reload the page from under whatever the user
* moved on to.
*/
ngOnDestroy(): void {
this.stopMakedataPoll()
}
private stopMakedataPoll(): void {
private pollMakedataCompletion() {
if (this.makedataPollInterval) {
clearInterval(this.makedataPollInterval)
this.makedataPollInterval = null
}
}
private pollMakedataCompletion() {
this.stopMakedataPoll()
let attempts = 0
const maxAttempts = 120 // 10 minutes at 5s intervals
@@ -600,7 +626,8 @@ export class AutomaticComponent implements OnInit, OnDestroy {
console.log(
'pollMakedataCompletion: makedata job is gone, reloading app'
)
this.stopMakedataPoll()
clearInterval(this.makedataPollInterval)
this.makedataPollInterval = null
// Reload the app fresh from the SASJobExecution URL
window.location.href = window.location.href.split('#')[0]
@@ -613,7 +640,8 @@ export class AutomaticComponent implements OnInit, OnDestroy {
console.warn(
'pollMakedataCompletion: timed out after 10 minutes, stopping poll'
)
this.stopMakedataPoll()
clearInterval(this.makedataPollInterval)
this.makedataPollInterval = null
}
}, 5000)
}
@@ -680,8 +708,24 @@ export class AutomaticComponent implements OnInit, OnDestroy {
this.deployService.downloadFile(content, filename, extension)
}
public openSasLogsModal() {
this.eventService.openSasLogsModal()
public async onJsonFileChange(event: any) {
let file = event.target.files[0]
this.jsonFile = await this.deployService.readFile(file)
}
public recreateDatabaseClicked(event: Event) {
;(<HTMLInputElement>event.target).checked === true
? (this.recreateDatabaseModal = true)
: ''
}
public clearUploadInput(event: Event) {
this.deployService.clearUploadInput(event)
}
public openSasRequestsModal() {
this.eventService.openRequestsModal()
}
public navigateToHome() {
@@ -1,155 +0,0 @@
<div class="dc-location">
<!--
A plain flex row, not a Clarity `clr-input-wrapper`: that class carries
`max-height: var(--clr-forms-input-wrapper-height)`, which clamps this
row to the input's height. The row is `flex-wrap: wrap`, so the buttons
overflow the clamp, and anything after the row (the folder browser panel)
then renders on top of them.
-->
<div class="dc-location-input">
<input
clrInput
[id]="inputId"
[name]="inputId"
[attr.aria-label]="ariaLabel"
[ngModel]="path"
(ngModelChange)="onPathChange($event)"
placeholder="Type a folder, or browse below"
/>
<button
type="button"
id="{{ inputId }}-browse"
class="btn btn-sm btn-outline dc-location-button"
(click)="toggleBrowse()"
>
{{ browsing ? 'Hide' : 'Browse' }}
</button>
<button
type="button"
id="{{ inputId }}-validate"
class="btn btn-sm btn-outline dc-location-button"
[disabled]="validating"
(click)="validate()"
>
@if (validating) {
<clr-spinner clrInline class="spinner-sm"></clr-spinner>
}
@if (!validating) {
Validate
}
</button>
</div>
@if (checkResult) {
<p
id="{{ inputId }}-validate-result"
class="dc-location-result"
[class.dc-location-ok]="checkResult.writeable"
[class.dc-location-bad]="!checkResult.writeable"
>
@if (checkResult.writeable) {
<clr-icon shape="check"></clr-icon>
<span>{{ checkResult.path }} is writeable</span>
}
@if (!checkResult.writeable) {
<clr-icon shape="exclamation-triangle"></clr-icon>
<span>{{ checkResult.msg }}</span>
}
</p>
}
@if (browsing) {
<div class="dc-browser" id="{{ inputId }}-browser">
<div class="dc-browser-bar">
<button
type="button"
id="{{ inputId }}-up"
class="btn btn-sm btn-link dc-browser-up"
[disabled]="listingIsRoot"
(click)="up()"
>
Up
</button>
<span class="dc-browser-path">
@if (listingIsRoot) {
Select a drive or root folder
}
@if (!listingIsRoot) {
{{ listingParent }}
}
</span>
@if (!listingIsRoot) {
<button
type="button"
id="{{ inputId }}-use-current"
class="btn btn-sm btn-outline dc-browser-use"
(click)="useCurrent()"
>
Use this folder
</button>
}
</div>
@if (loading) {
<div class="dc-browser-loading">
<span class="spinner spinner-sm"> Loading... </span>
</div>
}
@if (!loading && !!listingError) {
<p class="dc-location-bad" id="{{ inputId }}-browser-error">
<clr-icon shape="exclamation-triangle"></clr-icon>
<span>{{ listingError }}</span>
</p>
}
@if (!loading && !listingError) {
<ul class="dc-folder-list">
@for (folder of folders; track folder.filepath) {
<li class="dc-folder">
@if (!folder.locked) {
<button
type="button"
class="btn btn-sm btn-link dc-folder-open"
(click)="enter(folder)"
>
<clr-icon shape="folder"></clr-icon>
<span>{{ folder.filename }}</span>
</button>
}
@if (folder.locked) {
<span class="dc-folder-locked">
<clr-icon shape="lock"></clr-icon>
<span>{{ folder.filename }}</span>
</span>
}
@if (!!folder.info) {
<span class="dc-folder-info">{{ folder.info }}</span>
}
@if (!folder.locked) {
<button
type="button"
class="btn btn-sm btn-link dc-folder-use"
(click)="choose(folder)"
>
Use
</button>
}
</li>
}
@if (!folders.length) {
<li class="dc-folder dc-folder-empty" id="{{ inputId }}-no-folders">
No folders here
</li>
}
</ul>
}
@if (truncated) {
<p class="dc-browser-truncated" id="{{ inputId }}-truncated">
Only the first folders are shown - type the path if it is not listed.
</p>
}
</div>
}
</div>
@@ -1,131 +0,0 @@
.dc-location-input {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.dc-location-input input {
flex: 1 1 22rem;
min-width: 14rem;
}
.dc-location-button {
margin: 0;
}
.dc-location-result {
display: flex;
align-items: center;
gap: 6px;
margin: 6px 0 0;
font-size: 0.7rem;
}
.dc-location-ok {
color: #306b00;
}
.dc-location-bad {
color: #c21d00;
}
body[cds-theme='dark'] .dc-location-ok {
color: #7fd14a;
}
body[cds-theme='dark'] .dc-location-bad {
color: #ff8a73;
}
.dc-browser {
margin-top: 8px;
border: 1px solid light-dark(#ccc, #4a4a4a);
border-radius: 4px;
max-height: 18rem;
overflow: auto;
max-width: 100%;
}
.dc-browser-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid light-dark(#e8e8e8, #4a4a4a);
position: sticky;
top: 0;
background: light-dark(#fafafa, #1b1b1b);
z-index: 1;
}
.dc-browser-path {
flex: 1 1 auto;
font-family: monospace;
font-size: 0.7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dc-browser-up,
.dc-browser-use {
margin: 0;
}
.dc-browser-loading {
padding: 8px;
}
.dc-folder-list {
list-style: none;
margin: 0;
padding: 0;
}
.dc-folder {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
}
.dc-folder:hover {
background: light-dark(#f0f0f0, #2a2a2a);
}
.dc-folder-open {
margin: 0;
flex: 1 1 auto;
justify-content: flex-start;
}
.dc-folder-info {
font-size: 0.6rem;
opacity: 0.75;
}
.dc-folder-use {
margin: 0;
}
.dc-folder-empty {
opacity: 0.75;
font-size: 0.7rem;
padding: 8px;
}
.dc-browser-truncated {
margin: 0;
padding: 6px 8px;
font-size: 0.65rem;
opacity: 0.85;
}
.dc-folder-locked {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 auto;
opacity: 0.7;
}
@@ -1,497 +0,0 @@
import { Component, NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
import { SasService } from 'src/app/services/sas.service'
import { DcLocationComponent } from './dc-location.component'
describe('DcLocationComponent', () => {
let component: DcLocationComponent
let fixture: ComponentFixture<DcLocationComponent>
let requests: { url: string; data: any; config?: any }[]
let response: any
let rejection: any
let defer: boolean
let pending: {
url: string
data: any
resolve: (value: any) => void
reject: (reason: any) => void
}[]
const sasService = {
request: (url: string, data: any, config?: any) => {
requests.push({ url, data, config })
if (defer) {
return new Promise((resolve, reject) => {
pending.push({ url, data, resolve, reject })
})
}
if (rejection) return Promise.reject(rejection)
return Promise.resolve(response)
}
}
/**
* Hosts the picker so the spec can set `inputId` the way a deploy screen
* does - the element ids are only observable through the parent binding.
*/
@Component({
selector: 'app-dc-location-host',
template: `<app-dc-location
[(path)]="path"
[inputId]="inputId"
[ariaLabel]="ariaLabel"
></app-dc-location>`,
standalone: false
})
class DcLocationHostComponent {
path = ''
inputId = 'dcloc'
ariaLabel = 'Data Controller location'
}
const dirResponse = (parent: string, rows: any[], extra: any = {}) => ({
adapterResponse: {
dirinfo: [
{
PARENT: parent,
SEP: '/',
TRUNCATED: 'N',
PLATFORM: 'UNIX',
DEPTH: 1,
...extra
}
],
dirlist: rows
}
})
const folder = (filepath: string, level: number, filename?: string) => ({
FILEPATH: filepath,
FILENAME: filename || filepath.split('/').pop(),
FILE_OR_FOLDER: 'folder',
LEVEL: level,
MSG: ' '
})
beforeEach(() => {
requests = []
response = undefined
rejection = undefined
defer = false
pending = []
TestBed.configureTestingModule({
declarations: [DcLocationComponent, DcLocationHostComponent],
imports: [FormsModule],
providers: [{ provide: SasService, useValue: sasService }],
schemas: [NO_ERRORS_SCHEMA]
})
fixture = TestBed.createComponent(DcLocationComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
afterEach(() => {
TestBed.resetTestingModule()
})
it('treats the SAS blank padding as blank', () => {
expect(component.trimSas(' ')).toBe('')
expect(component.trimSas(undefined)).toBe('')
expect(component.trimSas(' /tmp ')).toBe('/tmp')
const rows = component.parseFolders(
[{ FILEPATH: ' ', FILENAME: ' ', FILE_OR_FOLDER: 'folder', LEVEL: 1 }],
false
)
expect(rows.length).toBe(0)
})
it('browses from the typed path, trimmed', async () => {
component.path = ' /tmp '
component.toggleBrowse()
await fixture.whenStable()
expect(requests.length).toBe(1)
expect(requests[0].url).toBe('admin/makedata')
expect(requests[0].data.fromjs[0].ACTION).toBe('DIRLIST')
expect(requests[0].data.fromjs[0].PARENT).toBe('/tmp')
})
it('sends no context when none was supplied', async () => {
// SAS 9 and SASjs Server have no compute contexts: the probes must run
// exactly as they did before the context input existed.
component.path = '/tmp'
component.toggleBrowse()
await fixture.whenStable()
expect(requests[0].config).toEqual({})
})
it('runs the probes in the chosen compute context', async () => {
// On Viya the listing and the write check see the file system as the
// context's batch identity sees it, so the probe has to name the context
// the deployment will use.
component.computeContextName = 'Compute Reusable'
component.path = '/tmp'
component.toggleBrowse()
await fixture.whenStable()
expect(requests[0].config).toEqual({ contextName: 'Compute Reusable' })
response = {
adapterResponse: {
checkwrite: [
{
PATH: '/tmp ',
PATH_EXISTS: 'Y',
IS_FOLDER: 'Y',
WRITEABLE: 'Y',
MSG: ' '
}
]
}
}
component.validate()
await fixture.whenStable()
expect(requests[1].config).toEqual({ contextName: 'Compute Reusable' })
})
it('shows the platform roots when the parent is blank', async () => {
response = dirResponse(' ', [folder('/', 0, '/')])
component.path = ''
component.toggleBrowse()
await fixture.whenStable()
expect(component.listingIsRoot).toBeTrue()
expect(component.folders.length).toBe(1)
expect(component.folders[0].filepath).toBe('/')
})
it('does not offer the parent itself as a folder to drill into', async () => {
response = dirResponse('/tmp', [
folder('/tmp', 0, 'tmp'),
folder('/tmp/aa', 1, 'aa')
])
component.path = '/tmp'
component.toggleBrowse()
await fixture.whenStable()
expect(component.folders.length).toBe(1)
expect(component.folders[0].filepath).toBe('/tmp/aa')
expect(component.listingIsRoot).toBeFalse()
})
it('drills into a folder', async () => {
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
component.path = '/tmp'
component.toggleBrowse()
await fixture.whenStable()
response = dirResponse('/tmp/aa', [folder('/tmp/aa/bb', 1, 'bb')])
component.enter(component.folders[0])
await fixture.whenStable()
expect(requests.length).toBe(2)
expect(requests[1].data.fromjs[0].PARENT).toBe('/tmp/aa')
})
it('takes a folder as the answer and closes the browser', async () => {
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
const emitted: string[] = []
component.pathChange.subscribe((value: string) => emitted.push(value))
component.path = '/tmp'
component.toggleBrowse()
await fixture.whenStable()
component.choose(component.folders[0])
expect(emitted).toEqual(['/tmp/aa'])
expect(component.browsing).toBeFalse()
})
it('goes up one level', async () => {
response = dirResponse('/tmp/aa', [folder('/tmp/aa/bb', 1, 'bb')])
component.path = '/tmp/aa'
component.toggleBrowse()
await fixture.whenStable()
component.up()
await fixture.whenStable()
expect(requests[1].data.fromjs[0].PARENT).toBe('/tmp')
})
describe('parentOf', () => {
it('walks unix paths up to a root that is not empty', () => {
expect(component.parentOf('/tmp/a', '/')).toBe('/tmp')
expect(component.parentOf('/tmp', '/')).toBe('/')
expect(component.parentOf('/', '/')).toBe('')
})
it('keeps a drive root, and goes to the roots from it', () => {
expect(component.parentOf('C:\\Users', '\\')).toBe('C:\\')
expect(component.parentOf('C:\\', '\\')).toBe('')
})
})
it('reports a writeable folder', async () => {
response = {
adapterResponse: {
checkwrite: [
{
PATH: '/tmp ',
PATH_EXISTS: 'Y',
IS_FOLDER: 'Y',
WRITEABLE: 'Y',
MSG: ' '
}
]
}
}
component.path = '/tmp'
component.validate()
await fixture.whenStable()
expect(requests[0].data.fromjs[0].ACTION).toBe('WRITECHECK')
expect(requests[0].data.fromjs[0].PATH).toBe('/tmp')
expect(component.checkResult?.writeable).toBeTrue()
expect(component.checkResult?.path).toBe('/tmp')
})
it('explains a refusal instead of showing a blank message', async () => {
response = {
adapterResponse: {
checkwrite: [
{
PATH: '/root/private ',
PATH_EXISTS: 'Y',
IS_FOLDER: 'Y',
WRITEABLE: 'N',
MSG: ' '
}
]
}
}
component.path = '/root/private'
component.validate()
await fixture.whenStable()
expect(component.checkResult?.writeable).toBeFalse()
expect((component.checkResult?.msg || '').trim()).toBeTruthy()
})
it('reports the service error rather than a generic failure', async () => {
rejection = { adapterResponse: { error: 'Invalid path' } }
component.path = '%nrstr(&path)'
component.validate()
await fixture.whenStable()
expect(component.checkResult?.writeable).toBeFalse()
expect(component.checkResult?.msg).toBe('Invalid path')
})
it('does not call the service when there is nothing to validate', async () => {
component.path = ' '
component.validate()
expect(requests.length).toBe(0)
expect(component.checkResult?.msg).toBe('Type or pick a folder first')
})
it('accepts the type in any case, and keeps entries it could not open', async () => {
response = dirResponse('/tmp', [
{
FILEPATH: '/tmp/upper',
FILENAME: 'upper',
FILE_OR_FOLDER: 'FOLDER',
LEVEL: 1,
MSG: ' '
},
{
FILEPATH: '/tmp/quiet',
FILENAME: 'quiet',
FILE_OR_FOLDER: 'Folder',
LEVEL: 1,
MSG: ' '
},
{
FILEPATH: '/tmp/shut',
FILENAME: 'shut',
FILE_OR_FOLDER: 'locked',
LEVEL: 1,
MSG: 'Permission denied'
},
{
FILEPATH: '/tmp/readme.txt',
FILENAME: 'readme.txt',
FILE_OR_FOLDER: 'file',
LEVEL: 1,
MSG: ' '
}
])
component.path = '/tmp'
component.toggleBrowse()
await fixture.whenStable()
const names = component.folders.map((folder) => folder.filename)
expect(names).toEqual(['upper', 'quiet', 'shut'])
expect(component.folders[2].locked).toBeTrue()
expect(component.folders[0].locked).toBeFalse()
})
it('renders a locked entry without the buttons that would use it', async () => {
response = dirResponse('/tmp', [
{
FILEPATH: '/tmp/shut',
FILENAME: 'shut',
FILE_OR_FOLDER: 'locked',
LEVEL: 1,
MSG: 'Permission denied'
}
])
component.path = '/tmp'
component.toggleBrowse()
await fixture.whenStable()
fixture.detectChanges()
const rows = fixture.nativeElement.querySelectorAll('.dc-folder')
expect(rows.length).toBe(1)
expect(rows[0].querySelector('.dc-folder-use')).toBeNull()
expect(rows[0].querySelector('.dc-folder-open')).toBeNull()
expect(rows[0].querySelector('.dc-folder-locked')).toBeTruthy()
expect(rows[0].textContent).toContain('Permission denied')
})
it('renders the browser only while browsing, and the verdict after a check', async () => {
response = dirResponse('/tmp', [folder('/tmp/aa', 1, 'aa')])
component.path = '/tmp'
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('#dcloc-browser')).toBeNull()
component.toggleBrowse()
await fixture.whenStable()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('#dcloc-browser')).toBeTruthy()
response = {
adapterResponse: {
checkwrite: [
{
PATH: '/tmp ',
PATH_EXISTS: 'Y',
IS_FOLDER: 'Y',
WRITEABLE: 'Y',
MSG: ' '
}
]
}
}
component.validate()
await fixture.whenStable()
fixture.detectChanges()
const verdict = fixture.nativeElement.querySelector(
'#dcloc-validate-result'
)
expect(verdict).toBeTruthy()
expect(verdict.textContent).toContain('is writeable')
})
it('labels the input for assistive tech, from the input', () => {
const hostFixture = TestBed.createComponent(DcLocationHostComponent)
hostFixture.detectChanges()
const input = (): HTMLInputElement =>
hostFixture.nativeElement.querySelector('#dcloc')
expect(input()).toBeTruthy()
expect(input().getAttribute('aria-label')).toBe('Data Controller location')
hostFixture.componentInstance.ariaLabel = 'Choose a staging folder'
hostFixture.detectChanges()
expect(input().getAttribute('aria-label')).toBe('Choose a staging folder')
})
it('ignores a listing that arrives after a newer one', async () => {
defer = true
component.browsing = true
component.list('a')
component.list('b')
expect(pending.length).toBe(2)
/* the newer request answers first, then the older one lands late */
pending[1].resolve(dirResponse('/b', [folder('/b/bb', 1, 'bb')]))
await fixture.whenStable()
pending[0].resolve(dirResponse('/a', [folder('/a/aa', 1, 'aa')]))
await fixture.whenStable()
fixture.detectChanges()
expect(component.listingParent).toBe('/b')
expect(component.folders.map((entry) => entry.filename)).toEqual(['bb'])
const rendered = Array.from(
fixture.nativeElement.querySelectorAll('.dc-folder')
).map((row: any) => row.textContent)
expect(rendered.join(' ')).toContain('bb')
expect(rendered.join(' ')).not.toContain('aa')
})
it('ignores an error from a listing that has been superseded', async () => {
defer = true
component.browsing = true
component.list('a')
component.list('b')
pending[1].resolve(dirResponse('/b', [folder('/b/bb', 1, 'bb')]))
await fixture.whenStable()
pending[0].reject({ adapterResponse: { error: 'Invalid path' } })
await fixture.whenStable()
fixture.detectChanges()
expect(component.listingError).toBe('')
expect(component.loading).toBeFalse()
expect(component.listingParent).toBe('/b')
})
it('derives every element id from the inputId', async () => {
const hostFixture = TestBed.createComponent(DcLocationHostComponent)
hostFixture.componentInstance.inputId = 'otherPicker'
hostFixture.detectChanges()
const host: HTMLElement = hostFixture.nativeElement
expect(host.querySelector('#otherPicker-browse')).toBeTruthy()
expect(host.querySelector('#otherPicker-validate')).toBeTruthy()
expect(host.querySelector('#DCBrowse')).toBeNull()
expect(host.querySelector('#DCValidate')).toBeNull()
host.querySelector<HTMLButtonElement>('#otherPicker-browse')!.click()
await hostFixture.whenStable()
hostFixture.detectChanges()
expect(host.querySelector('#otherPicker-browser')).toBeTruthy()
expect(host.querySelector('#otherPicker-up')).toBeTruthy()
expect(host.querySelector('#otherPicker-no-folders')).toBeTruthy()
expect(host.querySelector('#DCBrowser')).toBeNull()
expect(host.querySelector('#DCNoFolders')).toBeNull()
})
})
@@ -1,285 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import {
DcFolderEntry,
DcFolderRow,
DcWriteCheckResult,
DcWriteCheckRow
} from 'src/app/models/sas/dc-location.model'
import { SasService } from 'src/app/services/sas.service'
/**
* Folder picker for the deploy screens.
*
* The deploy screen asks where Data Controller should keep its control
* library, and that has to be answered before the control library exists - so
* the question is served by `makedata` itself (`ACTION=DIRLIST` to browse,
* `ACTION=WRITECHECK` to prove the folder is writeable).
*
* The typed value is always editable: browsing is a convenience over it, never
* a replacement, because the picker cannot see a folder the deployment user has
* no permission on and a customer may want to type a path that does not exist
* yet. The parent is listed one level at a time, so a folder holding tens of
* thousands of subfolders does not arrive in one response.
*/
@Component({
selector: 'app-dc-location',
templateUrl: './dc-location.component.html',
styleUrls: ['./dc-location.component.scss'],
standalone: false
})
export class DcLocationComponent {
@Input() path = ''
@Input() inputId = 'dcloc'
@Input() ariaLabel = 'Data Controller location'
/**
* The compute context the probes run in, on Viya. The listing and the write
* check see the file system as the context's batch identity sees it, which
* is the identity the deployment itself runs as - so the context must be
* chosen before the directory is probed. Empty on the platforms that have no
* contexts (SAS 9, SASjs Server), where the request runs as usual.
*/
@Input() computeContextName = ''
@Output() pathChange = new EventEmitter<string>()
/** panel state */
public browsing = false
public loading = false
public folders: DcFolderEntry[] = []
public listingParent = ''
public listingIsRoot = true
public truncated = false
public listingError = ''
/** validate state */
public validating = false
public checkResult: DcWriteCheckResult | null = null
private separator = '/'
/**
* Counts the DIRLIST calls made, so a response to an older request can be
* recognised and dropped. Two overlapping requests may arrive in either
* order, and the older one must not overwrite the newer listing.
*/
private listRequestId = 0
constructor(private sasService: SasService) {}
/**
* The adapter config a directory probe runs with. On Viya the listing and
* the write check must run inside the chosen compute context - that is the
* identity the deployment itself will use, and any other context can see a
* different file system (or none). The adapter reads `contextName` from a
* request config the same way it does from its own configuration, so the
* probe follows the deployment's context without touching global state.
* With no context supplied the request runs exactly as before.
*/
private probeConfig(): { contextName?: string } {
return this.computeContextName
? { contextName: this.computeContextName }
: {}
}
/**
* SAS has no zero length character value, so a blank field arrives as a single
* space. Everything read from the service goes through here before it is
* tested, or `' '` reads as a value.
*/
public trimSas(value: any): string {
if (value === null || value === undefined) return ''
return String(value).trim()
}
public onPathChange(value: string) {
this.path = value
this.checkResult = null
this.pathChange.emit(value)
}
public toggleBrowse() {
this.browsing = !this.browsing
if (this.browsing) this.list(this.trimSas(this.path))
}
/** Lists one level below `parent`. A blank parent is the platform roots. */
public list(parent: string) {
const requestId = ++this.listRequestId
this.loading = true
this.listingError = ''
const data = {
fromjs: [{ ACTION: 'DIRLIST', PARENT: parent, DEPTH: 1, MAXOBS: 500 }]
}
this.sasService
.request('admin/makedata', data, this.probeConfig(), {
suppressErrorAbortModal: true,
suppressSuccessAbortModal: true
})
.then((res: any) => {
if (requestId !== this.listRequestId) return
const response = res?.adapterResponse || {}
const info = (response.dirinfo || [])[0] || {}
const listedParent = this.trimSas(info.PARENT)
this.separator = this.trimSas(info.SEP) || '/'
this.truncated = this.trimSas(info.TRUNCATED) === 'Y'
this.listingParent = listedParent
this.listingIsRoot = listedParent === ''
this.folders = this.parseFolders(response.dirlist, this.listingIsRoot)
this.loading = false
})
.catch((err: any) => {
if (requestId !== this.listRequestId) return
this.folders = []
this.loading = false
this.listingError = this.errorText(err)
})
}
/**
* Keeps the folders, and the entries the service could not open. The type is
* compared case insensitively: mpe_dirlist writes it lowercase, but a value
* that arrived uppercased would otherwise empty the picker. The row at LEVEL 0
* is the parent being listed, which is not something to drill into - except
* when the parent is blank, where the level 0 rows *are* the platform roots.
*/
public parseFolders(rows: any, rootListing: boolean): DcFolderEntry[] {
const list: DcFolderRow[] = Array.isArray(rows) ? rows : []
return list
.filter((row) =>
['folder', 'locked'].includes(
this.trimSas(row?.FILE_OR_FOLDER).toLowerCase()
)
)
.filter((row) => rootListing || Number(row?.LEVEL) > 0)
.map((row) => ({
filepath: this.trimSas(row?.FILEPATH),
filename: this.trimSas(row?.FILENAME),
level: Number(row?.LEVEL) || 0,
info: this.trimSas(row?.MSG),
locked: this.trimSas(row?.FILE_OR_FOLDER).toLowerCase() === 'locked'
}))
.filter((folder) => !!folder.filepath)
}
/** Drills into a folder. */
public enter(folder: DcFolderEntry) {
this.list(folder.filepath)
}
/** Takes a folder as the answer. */
public choose(folder: DcFolderEntry) {
this.applyPath(folder.filepath)
}
/** Takes the folder being listed - the one the user is standing in. */
public useCurrent() {
this.applyPath(this.listingParent)
}
/** Goes up one level; from the top of a drive it goes back to the roots. */
public up() {
this.list(this.parentOf(this.listingParent, this.separator))
}
/** The parent of a folder, without asking the server for it. */
public parentOf(folder: string, sep: string): string {
let value = this.trimSas(folder)
while (value.length > 1 && value.endsWith(sep)) {
/* a drive root keeps its separator: C:\ is not C: */
if (value.length === 3 && value.charAt(1) === ':') break
value = value.slice(0, -1)
}
const index = value.lastIndexOf(sep)
if (index < 0) return ''
if (index === 0) return value === sep ? '' : sep
/* C:\Users -> C:\ , and C:\ -> the roots */
if (index === 2 && value.charAt(1) === ':') {
return value.length === 3 ? '' : value.slice(0, 3)
}
return value.slice(0, index)
}
private applyPath(value: string) {
this.browsing = false
this.listingError = ''
this.onPathChange(value)
}
/** Asks the service whether this folder exists, is a folder, and is writeable. */
public validate() {
const target = this.trimSas(this.path)
this.checkResult = null
if (!target) {
this.checkResult = {
path: '',
exists: false,
isFolder: false,
writeable: false,
msg: 'Type or pick a folder first'
}
return
}
this.validating = true
this.sasService
.request(
'admin/makedata',
{ fromjs: [{ ACTION: 'WRITECHECK', PATH: target }] },
this.probeConfig(),
{ suppressErrorAbortModal: true, suppressSuccessAbortModal: true }
)
.then((res: any) => {
const response = res?.adapterResponse || {}
const row: DcWriteCheckRow = (response.checkwrite || [])[0] || {}
this.checkResult = this.parseWriteCheck(row, target)
this.validating = false
})
.catch((err: any) => {
this.checkResult = {
path: target,
exists: false,
isFolder: false,
writeable: false,
msg: this.errorText(err)
}
this.validating = false
})
}
public parseWriteCheck(
row: DcWriteCheckRow,
asked: string
): DcWriteCheckResult {
const exists = this.trimSas(row?.PATH_EXISTS) === 'Y'
const isFolder = this.trimSas(row?.IS_FOLDER) === 'Y'
const writeable = this.trimSas(row?.WRITEABLE) === 'Y'
const path = this.trimSas(row?.PATH) || asked
let msg = this.trimSas(row?.MSG)
if (!msg && !writeable) {
msg = exists
? `${path} cannot be written to by the deployment user`
: `${path} does not exist`
}
return { path, exists, isFolder, writeable, msg }
}
private errorText(err: any): string {
const fromAdapter = err?.adapterResponse?.error
if (fromAdapter) return String(fromAdapter)
if (err?.message) return String(err.message)
return 'The request to makedata failed'
}
}
@@ -11,6 +11,33 @@
To configure Data Controller for SAS©, please provide the following details:
</p>
<p class="m-0 mt-10">
Please specify a physical directory (on the
<strong> {{ SYSHOSTNAME }}</strong>
compute server) below, to which user
<strong>{{ SYSUSERID }}</strong> can write, on behalf of Data Controller.
</p>
<label class="mt-20 clr-control-label">DC Staging Directory</label>
<div class="mb-10 clr-control-container">
<div class="clr-input-wrapper">
<input
class="clr-input"
placeholder="Type in directory"
[(ngModel)]="dcDirectory"
/>
</div>
<!-- <button
(click)="directoryBrowse.click()"
class="mt-15 text-center"
class="btn btn-sm btn-outline"
>
Browse
</button>
<input #directoryBrowse hidden (change)="dirChange($event)" type="file" id="ctrl" webkitdirectory directory multiple/> -->
</div>
<p class="m-0 mt-10">
Below are the list of groups to which you belong. The group you select will
become the Data Controller Admin Group, and everyone in it will have
@@ -28,23 +55,6 @@
</select>
</clr-select-container>
<p class="m-0 mt-10">
Now specify a physical directory (on the
<strong> {{ SYSHOSTNAME }}</strong>
compute server) below, to which user
<strong>{{ SYSUSERID }}</strong> can write, on behalf of Data Controller.
</p>
<label for="dcDirectory" class="mt-20 clr-control-label"
>DC Staging Directory</label
>
<div class="mb-10 clr-control-container">
<app-dc-location
[(path)]="dcDirectory"
inputId="dcDirectory"
></app-dc-location>
</div>
<p class="m-0 mt-10">
Now, create the target directory and deploy the control library using the
admin group selected above:
@@ -1,231 +0,0 @@
import { Component, NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
import { ServerType } from '@sasjs/utils/types/serverType'
import { SasjsService } from 'src/app/services/sasjs.service'
import { SasService } from 'src/app/services/sas.service'
import { DcLocationComponent } from '../dc-location/dc-location.component'
import { SasjsConfiguratorComponent } from './sasjs-configurator.component'
/**
* The SAS9 / SASjs Server setup screen cannot deploy anything: it builds the
* one-off `makedata` URL and opens it, with the admin group and the directory
* the control library will live in. These tests pin the URL shape, because a
* wrong parameter name or a lost encode still produces a page that opens and
* merely fails inside SAS, where nobody is looking.
*/
describe('SasjsConfiguratorComponent (SAS9 / SASjs setup)', () => {
const build = (serverType: ServerType) => {
const sasService: any = {
getExecutionPath: () => '/SASStoredProcess/do',
sasjsMakedataChecking: () => Promise.resolve(false),
logout: () => undefined
}
const component = new SasjsConfiguratorComponent(
sasService,
{ getServerInfo: () => ({ subscribe: () => undefined }) } as any,
{ replaceState: () => undefined } as any
)
component.sasJsConfig = {
serverType,
serverUrl: 'https://sas9.example.com',
appLoc: '/User Folders/dc'
} as any
component.dcDirectory = '/tmp/DataController'
component.dcAdminGroup = 'DCADMINS'
const opened: string[] = []
spyOn(window, 'open').and.callFake((url?: any) => {
opened.push(String(url))
return null
})
return { component, opened, sasService }
}
it('opens makedata on SAS9 with the admin group and the directory', () => {
const { component, opened } = build(ServerType.Sas9)
component.makeData()
expect(opened.length).toBe(1)
expect(opened[0]).toContain(
'_program=/User Folders/dc/services/admin/makedata'
)
expect(opened[0]).toContain('admin=DCADMINS')
expect(opened[0]).toContain('path=%2Ftmp%2FDataController')
expect(opened[0]).toContain('action=MAKEDATA')
expect(opened[0]).toContain('_debug=131')
})
it('stays on the page origin for SAS9 rather than the configured server', () => {
const { component, opened } = build(ServerType.Sas9)
component.makeData()
// A SAS9 deployment is reached through the URL the user is already on; the
// configured serverUrl would point the browser at a host it may not reach.
expect(opened[0].startsWith(window.location.origin)).toBe(true)
expect(opened[0]).not.toContain('sas9.example.com')
})
it('keeps the directory readable for SASjs Server', () => {
const { component, opened } = build(ServerType.Sasjs)
component.makeData()
// The SASjs branch does not encode the path, so a plain directory stays as
// written; encoding it there would make makedata look for a folder with %2F
// in its name.
expect(opened[0]).toContain('path=/tmp/DataController')
})
it('checks the SASjs Server deployment before reloading the app', () => {
const { component, sasService } = build(ServerType.Sasjs)
const spy = spyOn(sasService, 'sasjsMakedataChecking').and.returnValue(
Promise.resolve(false)
)
component.makeData()
expect(spy).toHaveBeenCalled()
})
})
/**
* The deploy screen pre-fills the directory from SYSSCPL. A token it does not
* recognise used to fall through to the Windows default, which offers the
* server a drive path it refuses - so an unknown value has to mean POSIX.
*/
describe('SasjsConfiguratorComponent directory default', () => {
const directoryFor = async (sysscpl: any) => {
const sasService: any = {
request: () =>
Promise.resolve({
adapterResponse: {
MF_GETUSER: 'demo',
SYSUSERID: '0',
SYSHOSTNAME: 'host',
SYSVLONG: 'browser',
SYSSCPL: sysscpl,
groups: [{ GROUPNAME: 'AllUsers' }]
}
})
}
const sasjsService: any = {
getServerInfo: () => ({ subscribe: () => undefined })
}
const component = new SasjsConfiguratorComponent(sasService, sasjsService, {
replaceState: () => undefined
} as any)
component.getUserGroups()
// the default is set in the promise callback this call schedules
await Promise.resolve()
await Promise.resolve()
return component.dcDirectory
}
it('gives the POSIX path for the browser host, whose token is not a platform', async () => {
expect(await directoryFor('browser')).toBe('/tmp/DataController')
})
it('gives the POSIX path for the unix tokens', async () => {
expect(await directoryFor('LINUX')).toBe('/tmp/DataController')
expect(await directoryFor('AIX')).toBe('/tmp/DataController')
})
it('gives the drive path for the Windows tokens', async () => {
expect(await directoryFor('WIN')).toBe('C:\\DataController')
expect(await directoryFor('DNTHOST')).toBe('C:\\DataController')
})
it('gives the POSIX path when the token is absent', async () => {
expect(await directoryFor(undefined)).toBe('/tmp/DataController')
})
it('gives the POSIX path for an unknown token that starts with W or D', async () => {
// a prefix test would put both of these on the drive path - the guesswork
// this rule exists to remove
expect(await directoryFor('WORKSTATION')).toBe('/tmp/DataController')
expect(await directoryFor('DOCKER')).toBe('/tmp/DataController')
})
})
/**
* The rendered order. DC on a SASjs Server or SAS9 estate is configured by the
* admin group selected here, and the staging directory is the last question
* because it is the one the picker has to prove - the same order the Viya
* screen asks them in. A template edit that put the directory back on top
* would compile and deploy fine and ask the operator to choose a location
* before telling DC who administers it.
*/
describe('SasjsConfiguratorComponent field order', () => {
@Component({
selector: 'app-sasjs-configurator-host',
template: '<app-sasjs-configurator></app-sasjs-configurator>',
standalone: false
})
class HostComponent {}
let fixture: ComponentFixture<HostComponent>
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
SasjsConfiguratorComponent,
HostComponent,
DcLocationComponent
],
imports: [FormsModule],
providers: [
{
provide: SasService,
useValue: {
getExecutionPath: () => '/SASStoredProcess/do',
request: () =>
Promise.resolve({ adapterResponse: { SYSSCPL: 'Linux' } }),
getSasjsConfig: () => ({}),
sasjsMakedataChecking: () => Promise.resolve(false),
getUserGroups: () => ({ subscribe: () => undefined }),
logout: () => undefined
}
},
{
provide: SasjsService,
useValue: {
getServerInfo: () => ({ subscribe: () => undefined })
}
}
],
schemas: [NO_ERRORS_SCHEMA]
})
fixture = TestBed.createComponent(HostComponent)
fixture.detectChanges()
})
afterEach(() => {
TestBed.resetTestingModule()
})
it('asks for the admin group before the staging directory', () => {
const labels = Array.from(
fixture.nativeElement.querySelectorAll('.clr-control-label')
).map((label: unknown) => ((label as HTMLElement).textContent || '').trim())
const admin = labels.findIndex((l) =>
l.toLowerCase().includes('admin group')
)
const directory = labels.findIndex((l) =>
l.toLowerCase().includes('staging directory')
)
expect(admin).toBeGreaterThanOrEqual(0)
expect(directory).toBeGreaterThanOrEqual(0)
expect(admin).toBeLessThan(directory)
})
})
@@ -89,22 +89,20 @@ export class SasjsConfiguratorComponent implements OnInit {
user who is installing Data Controller. This path should be
appropriate to the server type, ie windows or linux.
We can use the SYSSCPL variable from the service response above, and
derive the OS from it.
derive the OS based on the first letter.
The list of values is available here:
https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/mcrolref/n0e7s8lf147kazn17u45trpwb6sw.htm
Windows is recognised by its own tokens (WIN, DNTHOST). Anything
else gets the POSIX path: the previous test looked for the first
letter of a unix token, so a value it did not know - the
browser-hosted build answers "browser" - fell through to a drive
path, and the server then refused the folder it had been offered.
*/
const sysscpl = String(res.adapterResponse.SYSSCPL || '').toUpperCase()
this.dcDirectory =
this.tmpDirectories[
['WIN', 'DNTHOST'].includes(sysscpl) ? 'windows' : 'linux'
['L', 'H', 'A', 'S'].includes(
res.adapterResponse.SYSSCPL.substring(0, 1)
)
? 'linux'
: 'windows'
]
this.dcAdminGroupList = res.adapterResponse.groups
@@ -147,7 +145,7 @@ export class SasjsConfiguratorComponent implements OnInit {
const url = `${
serverUrl ? serverUrl : ''
}${executor}?_program=${root}/services/admin/makedata&action=MAKEDATA&admin=${
}${executor}?_program=${root}/services/admin/makedata&admin=${
this.dcAdminGroup
}&path=${dcDirectory}${_debug}`
-29
View File
@@ -1,29 +0,0 @@
/**
* The URL a Data Controller instance runs at when it is not embedded: the same
* URL, without the parts that make it an embed - the `embed` switch, the
* `va_parmap` entries, and the hash route.
*
* The rest of the query is kept. On SAS 9 and Viya the app is addressed
* entirely through it (`/SASJobExecution/?_program=/.../startupservice`), so
* dropping the query would open a blank executor rather than the app, and any
* other parameter the deployment relies on would go the same way.
*
* Only these two parameters are embed parameters. A later parameter that
* happens to start with `va_` belongs to the app, not the embed, so it stays.
*
* Parsing with URL also handles the relative hrefs the adapter can hand back,
* which plain string splicing cannot.
*/
const EMBED_PARAMS = ['embed', 'va_parmap']
export const standaloneUrl = (href: string): string => {
const url = new URL(href, window.location.origin)
url.hash = ''
for (const key of Array.from(url.searchParams.keys())) {
if (EMBED_PARAMS.includes(key.toLowerCase())) {
url.searchParams.delete(key)
}
}
return url.toString()
}
@@ -459,43 +459,6 @@
<!--<hr>-->
<!--{{ validationDone }}-->
<!-- VA embed: something about a `va_parmap` entry in this report
object's URL needs the report author's attention - it could not be
used, it was applied but AND-ed against another parameter, or it
names a parameter VA never sends. Shown as a banner, not just a
console warning, because
the entries are written by the report author and a typo otherwise
looks like a working filter over the whole table. -->
@if (isVaEmbed && vaMappingWarnings.length) {
<clr-alert
class="va-parmap-warning"
[clrAlertType]="'warning'"
[clrAlertClosable]="false"
>
<clr-alert-item>
<div class="alert-text">
<strong>VA parameter mapping problem</strong>
@for (warning of vaMappingWarnings; track warning) {
<div>{{ warning }}</div>
}
<!-- The reader is the report author, who can only fix this in
the report: name the parameter they edit, and say what a
correct entry looks like, so the message is actionable
without the docs. -->
<div>
Fix: edit the <code>va_parmap</code> entries in the URL of
this Data Controller object in the report. An entry is
<code>parameter-column</code>, optionally
<code>:ADD(anchor,offset)</code> or
<code>:RANGE(period,offset)</code> - for example
<code>va_parmap=SEL_DATE-D_DATE:ADD(QUARTER_START,-1)</code
>.
</div>
</div>
</clr-alert-item>
</clr-alert>
}
<div class="hot-wrapper clr-flex-1">
<hot-table
#hotInstance
@@ -27,15 +27,3 @@
font-style: italic;
opacity: 0.7;
}
/* VA embed: `va_parmap` diagnostic banner. Clarity's alert lays `.alert-title`
and `.alert-text` out on one row, which pushes a long message off the right
edge; this keeps the whole message in the flow and wraps it. */
.va-parmap-warning {
width: 100%;
.alert-text {
white-space: normal;
overflow-wrap: anywhere;
}
}
+190 -289
View File
@@ -15,7 +15,6 @@ import { ActivatedRoute, Router } from '@angular/router'
import Handsontable, { CellRange } from 'handsontable'
import { Subject, Subscription } from 'rxjs'
import { sanitiseForSas } from '../shared/utils/sanitise'
import { fixGridAria } from '../shared/utils/grid-aria'
import { SasStoreService } from '../services/sas-store.service'
type AOA = any[][]
@@ -27,10 +26,7 @@ import { CellValidationSource } from '../models/CellValidationSource'
import { FileUploader } from '../models/FileUploader.class'
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
import { HotTableInterface } from '../models/HotTable.interface'
import {
buildExportMenuItem,
EXPORT_MENU_KEY
} from '../shared/hot-export/hot-export.util'
import { buildExportMenuItem } from '../shared/hot-export/hot-export.util'
import {
$DataFormats,
DSMeta,
@@ -62,14 +58,6 @@ import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
import { excelRound } from '../shared/dc-validator/utils/excelRound'
import {
applyCaseTransform,
CaseTransform,
composeCaseHeaderTitle,
isCaseTransformSkipped,
resolveEnteredCaseTransform,
stripCaseHeaderTitle
} from '../shared/dc-validator/utils/caseTransform'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
import { getFormulaColumnNames } from '../shared/dc-validator/utils/getFormulaColumnNames'
@@ -85,7 +73,6 @@ import {
} from '../shared/dc-validator/utils/autoEscapedCellTracker'
import { HyperFormula } from 'hyperformula'
import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { VaParmapTarget } from '../shared/utils/parse-va-parmap'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html'
import { globals } from '../_globals'
@@ -120,7 +107,6 @@ import { VaFilterService } from '../services/va-filter.service'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
import { observeGridContainerResize } from './utils/refreshGridAfterContainerResize'
@Component({
selector: 'app-editor',
@@ -371,11 +357,7 @@ export class EditorComponent
hot.setDataAtRowProp(
row,
colName,
unescapeFormula(hot.getDataAtRowProp(row, colName)),
// a named source so the CASE transform leaves this write
// alone: it restores a live `=...` formula, which re-casing
// would corrupt
'unescapeFormula'
unescapeFormula(hot.getDataAtRowProp(row, colName))
)
clearAutoEscaped(this.autoEscapedCells, rowKey, colName)
}
@@ -424,10 +406,7 @@ export class EditorComponent
},
// Client-side export of the current grid view (or selection).
// skipLeadingCols=1 drops the `Delete?` housekeeping column (col 0).
[EXPORT_MENU_KEY]: buildExportMenuItem(
() => this.libds || 'export',
1
),
export_file: buildExportMenuItem(() => this.libds || 'export', 1),
sp1: {
name: '---------',
hidden: () => this.hotTable.readOnly === true
@@ -506,46 +485,6 @@ export class EditorComponent
get isVaEmbed() {
return globals.embed === 'va'
}
/**
* Explicit VA parameter -> DC column mappings (`va_parmap`) for this instance.
* Parsed once at app init (see parseVaParmap) and held on globals so the
* editor reloads a filter apply performs keep using the same mapping.
*/
get vaParamMap(): Map<string, VaParmapTarget> {
return globals.vaParamMap
}
/** `va_parmap` entries that name a column this table does not have. */
public vaUnresolvedMappings: string[] = []
/** `va_parmap` keys that no VA message has named (a parameter-side typo). */
public vaUnmatchedMappings: string[] = []
/** Parameters in this message that filter the same column (an AND-ed pair). */
public vaSameColumnMappings: string[] = []
/** Transform mappings this message cannot apply (non-temporal column or value). */
public vaTransformProblems: string[] = []
/** The warning text last written to the console, so repeated VA messages log once. */
private vaWarningsReported = ''
/**
* Everything wrong with this instance's `va_parmap` entries, in the report
* author's terms: entries that could not be parsed at all, plus entries that
* name a column the open table does not have. Shown as a banner in the editor
* - a report developer is usually not going to read the browser console.
*/
get vaMappingWarnings(): string[] {
return [...globals.vaParmapProblems, ...this.vaRuntimeWarnings]
}
/**
* The warnings this instance discovers for itself, i.e. everything except the
* parse-time problems - those are logged once at app init (`app.component`),
* so logging them again here would double every line in the console.
*/
get vaRuntimeWarnings(): string[] {
return [
...this.vaUnresolvedMappings,
...this.vaUnmatchedMappings,
...this.vaSameColumnMappings,
...this.vaTransformProblems
]
}
/**
* VA filter mode. Live = auto-apply each VA change; confirm = stage the change
* and wait for the user to click Apply. Backed by globals (toggled by the
@@ -636,8 +575,6 @@ export class EditorComponent
public hasBaseDropZoneOver = false
public hasAnotherDropZoneOver = false
public headerPks: string[] = []
/** Derived (computed) columns from MPE_DERIVED - read-only, and marked as such */
public derivedVars: string[] = []
public columnLevelSecurityFlag = false
public dateTimeHeaders: string[] = []
public timeHeaders: string[] = []
@@ -734,6 +671,8 @@ export class EditorComponent
public licenceState = this.licenceService.licenceState
private ariaObserver: MutationObserver | undefined
private ariaCheckInterval: any | undefined
private gridResizeObserver: ResizeObserver | undefined
private pasteListener: ((event: ClipboardEvent) => void) | undefined
@@ -1144,36 +1083,12 @@ export class EditorComponent
return this.headerPks.indexOf(col) > -1
}
/**
* The case transform to apply to values *entered* into `colName`, or
* undefined when the column must neither convert them nor advertise that it
* does. One predicate for the conversion (the beforeChange hook) and the
* header marker (afterGetColHeader) - see `resolveEnteredCaseTransform`.
*/
getEnteredCaseTransform(colName: string): CaseTransform | undefined {
return resolveEnteredCaseTransform(colName, this.dcValidator)
}
isReadonlyCol(col: string | number) {
const colRules = this.dcValidator?.getRule(col)
return colRules?.readOnly
}
/**
* A derived (computed) column - configured in MPE_DERIVED, created at read
* time. Read-only, and styled apart from an ordinary read-only column so the
* user can see that there is nothing to edit rather than that they may not.
*/
isDerivedCol(col: string | number) {
const name =
typeof col === 'number'
? (this.hotInstance?.colToProp(col) as string) || this.columnHeader[col]
: col
return !!name && this.derivedVars.includes(name.toUpperCase())
}
isColHeader(col: string) {
return this.headerArray.indexOf(col.toUpperCase()) > -1
}
@@ -2842,24 +2757,17 @@ export class EditorComponent
this.disableSubmit = true
this.submit = false
/* sasService has already shown the SAS message when it rejected
(the abort, or the adapter's error), so reporting it again here
stacked a second modal on top of the first - and read
'undefined' because it looked for a property that does not
exist. Only speak up when nothing was shown. */
if (!err?.errorShown) {
const errorText =
typeof err?.adapterResponse?.error === 'string'
? err.adapterResponse.error
: JSON.stringify(err?.adapterResponse ?? err)
const errorText =
typeof err.adapterRespnse === 'string'
? err.adapterRespnse
: JSON.stringify(err.adapterRespnse)
this.eventService.showAbortModal(
'editors/stagedata',
`Submit request failed\n${errorText}`,
null,
'Submit error'
)
}
this.eventService.showAbortModal(
'editors/stagedata',
`Submit request failed\n${errorText}`,
null,
'Submit error'
)
return false
})
@@ -3671,20 +3579,11 @@ export class EditorComponent
const msg = this.vaMessaging.latestMessage()
if (!msg) return false
const clauses = this.vaFilter.buildInitialClauses(msg, this.vaParamMap)
const clauses = this.vaFilter.buildInitialClauses(msg)
if (clauses.length === 0) return false
try {
// A `va_parmap` entry is a column the report author typed, so this
// request can legitimately fail: the table may not have that column, and
// validatefilter aborts on an unknown variable (dc/dc#404). Attempting it
// is still worth it - when the mapping is right the very first getdata is
// already filtered - but the failure must not stop the page. An abort
// modal here covers an editor that is about to load fine; the mapping
// banner names the bad entry once the real metadata arrives.
const res: any = await this.sasStoreService.saveQuery(libds, clauses, {
suppressErrorAbortModal: true
})
const res: any = await this.sasStoreService.saveQuery(libds, clauses)
const id = res?.result?.[0]?.FILTER_RK
const table = res?.result?.[0]?.FILTER_TABLE
if (id === undefined || table === undefined) return false
@@ -3787,6 +3686,18 @@ export class EditorComponent
ngOnDestroy() {
this._queryParams?.unsubscribe()
// Clean up the MutationObserver
if (this.ariaObserver) {
this.ariaObserver.disconnect()
this.ariaObserver = undefined
}
// Clean up the interval
if (this.ariaCheckInterval) {
clearInterval(this.ariaCheckInterval)
this.ariaCheckInterval = undefined
}
// Stop observing the grid container for resizes
if (this.gridResizeObserver) {
this.gridResizeObserver.disconnect()
@@ -3817,28 +3728,169 @@ export class EditorComponent
}
/**
* Re-measure the grid whenever its container settles. The observer itself
* lives in editor/utils/refreshGridAfterContainerResize.ts so the wiring is
* covered by a spec.
* Re-run height+render when the grid container settles.
*/
private observeGridResize() {
const el = this.hotInstance?.rootElement?.parentElement
if (!el) return
this.gridResizeObserver?.disconnect()
this.gridResizeObserver = observeGridContainerResize(
this.hotInstance,
this.hotInstance?.rootElement?.parentElement
)
this.gridResizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
const hot = this.hotInstance
if (!hot || hot.isDestroyed) return
this.updateSettingsSortSafe({ height: this.hotTable.height }, false)
hot.render()
})
})
this.gridResizeObserver.observe(el)
}
/**
* Corrects the ARIA markup Handsontable puts on its own grid wrapper - see
* shared/utils/grid-aria.ts for what is corrected and why. Scoped to this
* grid so it does not touch any other grid or component on the page.
* Fixes ARIA accessibility issues in the Handsontable component
* This addresses the accessibility report issues with treegrid and presentation roles
*/
private fixAriaAccessibility(): void {
fixGridAria(
this.hotInstance?.rootElement ??
this.hotTableComponent?.hotInstance?.rootElement
)
private fixAriaAccessibility() {
// Use a more aggressive approach to find and fix all ARIA issues
const fixAriaIssues = () => {
// Specifically target Handsontable wrapper elements that are causing issues
const hotWrappers = document.querySelectorAll(
'.ht-wrapper, .wtHolder, [id^="ht_"]'
)
hotWrappers.forEach((wrapper) => {
// Remove problematic ARIA attributes from Handsontable wrappers
wrapper.removeAttribute('role')
wrapper.removeAttribute('aria-rowcount')
wrapper.removeAttribute('aria-colcount')
wrapper.removeAttribute('aria-multiselectable')
})
// Find all elements with problematic ARIA roles in the entire document
const allTreegridElements = document.querySelectorAll('[role="treegrid"]')
const allPresentationElements = document.querySelectorAll(
'[role="presentation"]'
)
// Fix treegrid role issues - remove them completely as they're causing problems
allTreegridElements.forEach((element) => {
element.removeAttribute('role')
element.removeAttribute('aria-rowcount')
element.removeAttribute('aria-colcount')
element.removeAttribute('aria-multiselectable')
})
// Fix presentation role issues - remove them if they contain interactive elements
allPresentationElements.forEach((element) => {
const hasInteractiveChildren =
element.querySelectorAll(
'button, input, select, textarea, [tabindex], [onclick], [contenteditable]'
).length > 0
if (hasInteractiveChildren) {
element.removeAttribute('role')
}
})
// Also fix any elements with aria-rowcount="-1" which is problematic
const negativeRowCountElements = document.querySelectorAll(
'[aria-rowcount="-1"]'
)
negativeRowCountElements.forEach((element) => {
element.removeAttribute('aria-rowcount')
})
// Ensure proper table structure
const tableElements = document.querySelectorAll('table')
tableElements.forEach((table) => {
if (!table.getAttribute('role')) {
table.setAttribute('role', 'table')
}
// Ensure table headers have proper scope
const headerCells = table.querySelectorAll('th')
headerCells.forEach((th) => {
if (!th.getAttribute('scope')) {
th.setAttribute('scope', 'col')
}
})
})
// Add proper ARIA labels to interactive elements
const interactiveElements = document.querySelectorAll(
'button, input, select, textarea, [contenteditable]'
)
interactiveElements.forEach((element) => {
if (
!element.getAttribute('aria-label') &&
!element.getAttribute('aria-labelledby')
) {
const textContent = element.textContent?.trim()
if (textContent) {
element.setAttribute('aria-label', textContent)
}
}
})
}
// Run the fix immediately
fixAriaIssues()
// Run it again after a short delay to catch any dynamically created elements
setTimeout(fixAriaIssues, 100)
setTimeout(fixAriaIssues, 500)
setTimeout(fixAriaIssues, 1000)
setTimeout(fixAriaIssues, 2000)
// Set up a periodic check to ensure accessibility fixes are maintained
if (!this.ariaCheckInterval) {
this.ariaCheckInterval = setInterval(fixAriaIssues, 3000)
}
// Set up a MutationObserver to continuously monitor for new problematic elements
if (!this.ariaObserver) {
this.ariaObserver = new MutationObserver((mutations) => {
let shouldFix = false
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
(mutation.attributeName === 'role' ||
mutation.attributeName === 'aria-rowcount')
) {
shouldFix = true
}
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element
if (
element.hasAttribute('role') ||
element.hasAttribute('aria-rowcount')
) {
shouldFix = true
}
}
})
}
})
if (shouldFix) {
setTimeout(fixAriaIssues, 50)
}
})
// Start observing the entire document for changes
this.ariaObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'role',
'aria-rowcount',
'aria-colcount',
'aria-multiselectable'
]
})
}
}
initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
@@ -3889,11 +3941,8 @@ export class EditorComponent
// getdata serves a selection over DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT
// truncated to the first rows within the limit, and carries the reason
// here. Empty means the whole selection is in the grid - and a blank
// character arrives as a single blank (mp_jsonout cannot emit a zero-length
// string, since SAS has none), so trim it: whitespace is "no note", not a
// warning with an empty tooltip.
this.truncationNote = (response.data.sasparams[0].TRUNC_MSG || '').trim()
// here. Empty means the whole selection is in the grid.
this.truncationNote = response.data.sasparams[0].TRUNC_MSG || ''
const hot = this.hotInstance
@@ -3915,12 +3964,6 @@ export class EditorComponent
this.headerColumns = response.data.sasparams[0].COLHEADERS.split(',')
this.headerPks = response.data.sasparams[0].PK.split(' ')
// Derived (computed) columns, named by getdata from MPE_DERIVED - read-only
// through a READONLY DQ rule, and marked in the grid so they read as
// computed rather than merely non-editable.
this.derivedVars = (response.data.sasparams[0].DERIVEDVARS || '')
.split(' ')
.filter((col: string) => !!col)
this.columnLevelSecurityFlag = !!response.data.sasparams[0].CLS_FLAG
if (this.columnLevelSecurityFlag)
@@ -4295,60 +4338,9 @@ export class EditorComponent
// header columns styling - primary keys
const isPKCol = column && this.isColPk(column)
const isReadonlyCol = column && this.isReadonlyCol(column)
const isDerivedCol = column && this.isDerivedCol(column)
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
if (isReadonlyCol && !isPKCol && !isDerivedCol) {
th.classList.add('readonlyCell')
}
// a derived column is computed, not merely locked - give it its own
// hue and an 'fx' marker rather than the read-only dimming
if (isDerivedCol) {
th.classList.add('dc-derived-header')
// the label says what the column is - for a derived variable that
// is the configured VAR_LABEL, or the generated name=expression
// when the administrator left it blank. A label that merely
// repeats the name adds nothing, so it falls back to the rule.
const derivedLabel = (this.cols || [])
.filter((c: any) => c.NAME === column)
.map((c: any) => String(c.LABEL || '').trim())
.find(
(l: string) =>
l.length > 0 &&
l.toUpperCase() !== String(column).toUpperCase()
)
th.title = derivedLabel
? `${derivedLabel} - derived variable, computed at read time and cannot be edited`
: 'Derived variable - computed at read time and cannot be edited'
const firstDerivedCol = this.columnHeader.findIndex(
(name: string) => this.isDerivedCol(name)
)
if (col === firstDerivedCol) th.classList.add('dc-derived-first')
}
// A column whose CASE rule forces a case commits every entry in that
// case (see the beforeChange hook) - mark the header so the user can
// tell the capitals or the lower-case came from the grid and not
// from them. The cell shows the value the grid actually holds, so
// nothing here re-cases a value the user has not touched. Gated on
// the hook's own predicate, so a formula, read-only or derived column
// carries no marker claiming a conversion that will not happen; the
// title is composed rather than appended because Handsontable reuses
// this th across renders.
const caseTransform = column
? this.getEnteredCaseTransform(column)
: undefined
if (caseTransform) {
th.classList.add('dc-case-header')
th.title = composeCaseHeaderTitle(th.title, caseTransform)
} else {
// the header element is reused across renders, so dropping the
// badge without dropping the sentence would leave a tooltip
// claiming a conversion that no longer happens
th.classList.remove('dc-case-header')
th.title = stripCaseHeaderTitle(th.title)
}
if (isReadonlyCol && !isPKCol) th.classList.add('readonlyCell')
// Remove header arrow from Delete column
if (col === 0) {
@@ -4364,7 +4356,6 @@ export class EditorComponent
cellProperties: Handsontable.CellProperties
) => {
const isReadonlyCol = col && this.isReadonlyCol(col)
const isDerivedCol = col && this.isDerivedCol(col)
// Check if this cell should be marked as invalid due to duplicate primary key values
// Only applies to primary key columns (col 1 through readOnlyFields)
@@ -4388,26 +4379,7 @@ export class EditorComponent
}
// Add readonlyCell class for readonly columns to maintain original styling
if (isDerivedCol) {
// a derived cell carries its own tint; the read-only dimming would
// wash it out, and "greyed out" is a different message from
// "computed here"
classes = classes.filter((c: string) => c !== 'readonlyCell')
if (!classes.includes('dc-derived-cell')) {
classes.push('dc-derived-cell')
}
// derived columns are appended after the physical ones - mark the
// start of the block so it reads as one group
const firstDerivedCol = this.columnHeader.findIndex(
(name: string) => this.isDerivedCol(name)
)
if (
col === firstDerivedCol &&
!classes.includes('dc-derived-first')
) {
classes.push('dc-derived-first')
}
} else if (isReadonlyCol && !classes.includes('readonlyCell')) {
if (isReadonlyCol && !classes.includes('readonlyCell')) {
classes.push('readonlyCell')
}
@@ -4537,31 +4509,6 @@ export class EditorComponent
}
}
// CASE=UPCASE / CASE=LOWCASE: commit the value in the case the rule
// requires, so it is not flagged as invalid and retyped. Applied here
// (rather than in afterChange) because it runs before the commit - the
// cursor and selection stay put, undo restores the previous value, and
// an edit, a paste range and a fill-down all take the same path. The
// CASE validation itself still stands for values that arrive from
// anywhere else (API, staged data, Excel import).
//
// Two exclusions. `loadData` is the grid re-sending its own dataset on
// every updateSettings({data}) - converting there would rewrite the
// service's values and show rows as modified before the user has
// touched them (the unchanged baseline is cloned from what the grid
// hands back). Undo and redo restore a previous value rather than
// entering a new one, and re-casing it would hand back something other
// than what the user asked to get back. The rest of the skipped list
// and the formula/read-only exclusions live in
// CASE_TRANSFORM_SKIPPED_SOURCES and getEnteredCaseTransform, which the
// header marker uses too.
if (!isCaseTransformSkipped(source) && typeof newValue === 'string') {
const caseTransform = this.getEnteredCaseTransform(colName)
if (caseTransform) {
change[3] = applyCaseTransform(newValue, caseTransform)
}
}
// A `=`-led value the user types or pastes is never auto-escaped
// (see character-column-formula-plan.md) - it's left exactly as
// entered, which genuinely evaluates as a live formula since
@@ -5021,53 +4968,7 @@ export class EditorComponent
this.updateVaFilterText(msg)
// A `va_parmap` entry naming a column this table does not have is skipped by
// buildClauses. Report it (banner + console, once per distinct set) so the
// report author sees a misconfiguration instead of an unfiltered grid.
this.vaUnresolvedMappings = this.vaFilter.unresolvedMappings(
msg,
this.vaParamMap,
this.cols,
this.libds
)
// A key naming no parameter VA has ever sent is a typo on the parameter
// side: the mapping never applies. Only judged once parameters are actually
// arriving, since VA sends just the parameters its query uses.
this.vaUnmatchedMappings = this.vaFilter.unmatchedMappings(
this.vaParamMap,
this.vaMessaging.seenParamKeys,
this.vaMessaging.lastMessageParamsStable
)
// Two parameters filtering one column are AND-ed, so differing values leave
// an empty grid. Reported, not rewritten: turning that AND into an OR is a
// filter-semantics decision, taken in buildClauses and not in a diagnostic.
this.vaSameColumnMappings = this.vaFilter.sameColumnMappings(
msg,
this.vaParamMap,
this.cols
)
// A transform that cannot run here (char column, value that is not a date) is
// skipped by the clause builder, so the banner has to say why.
this.vaTransformProblems = this.vaFilter.unsupportedTransforms(
msg,
this.vaParamMap,
this.cols
)
const warnings = this.vaRuntimeWarnings.join(' | ')
if (warnings !== this.vaWarningsReported) {
this.vaWarningsReported = warnings
for (const warning of this.vaRuntimeWarnings) {
// eslint-disable-next-line no-console
console.warn('Data Controller: ' + warning)
}
}
const clauses = this.vaFilter.buildClauses(
msg,
labelToVar,
this.cols,
this.vaParamMap
)
const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
const signature = this.vaFilter.signature(clauses)
if (signature === this.vaMessaging.filterSignature) {
@@ -1,279 +0,0 @@
import Handsontable from 'handsontable'
import {
applyCaseTransform,
CASE_HEADER_HINTS,
CaseTransform,
CaseTransformSource,
composeCaseHeaderTitle,
isCaseTransformSkipped,
resolveEnteredCaseTransform
} from '../../shared/dc-validator/utils/caseTransform'
/**
* Mechanics coverage for the CASE rule transform, against a real Handsontable
* instance - the same technique the other grid specs in this folder use.
*
* editor.component.ts has no TestBed harness, so the hook below mirrors its
* beforeChange branch using the real predicate (`resolveEnteredCaseTransform`)
* and the real transform function: what is being proven here is the part that
* belongs to Handsontable and is easy to get wrong - that mutating the pending
* change commits the converted value for an edit, a paste range and a
* fill-down, that it does not touch a dataset handed back to the grid on load,
* that undo still restores what was there before, and that the columns the
* predicate refuses are left exactly as entered.
*/
describe('CASE rule transform on entry integration', () => {
// the rule table DcValidator.getCaseTransform builds from MPE_VALIDATIONS
const caseRules: Record<string, CaseTransform> = {
UPPER_COL: 'upper',
LOWER_COL: 'lower',
READONLY_COL: 'upper'
}
// a SOFTFORMULA column is user-editable and holds a live formula string
const formulaCols = ['SOFT_FORMULA_COL']
// a read-only column: the grid refuses the edit, and the hook refuses it too
const readonlyCols = ['READONLY_COL']
const columnNames = [
'KEY',
'UPPER_COL',
'LOWER_COL',
'LABEL_COL',
'SOFT_FORMULA_COL',
'READONLY_COL'
]
// the three methods resolveEnteredCaseTransform asks of DcValidator
const validator: CaseTransformSource = {
getRule: (col) => ({ readOnly: readonlyCols.includes(String(col)) }),
hasDqRules: (col, rules) =>
formulaCols.includes(col) && rules.includes('SOFTFORMULA'),
getCaseTransform: (col) => caseRules[String(col)]
}
let container: HTMLDivElement
let hot: Handsontable
const buildGrid = (data: Record<string, any>[]) => {
hot = new Handsontable(container, {
data,
columns: columnNames.map((name) => ({ data: name })),
licenseKey: 'non-commercial-and-evaluation'
})
// mirrors editor.component.ts's beforeChange branch
hot.addHook('beforeChange', (changes: any[], source: string) => {
if (!changes || isCaseTransformSkipped(source)) return
for (const change of changes) {
if (!change) continue
const [, prop, , newValue] = change
const colName =
typeof prop === 'string' ? prop : (hot.colToProp(prop) as string)
if (typeof newValue !== 'string') continue
const transform = resolveEnteredCaseTransform(colName, validator)
if (!transform) continue
change[3] = applyCaseTransform(newValue, transform)
}
})
hot.render()
return hot
}
const row = () => ({
KEY: 1,
UPPER_COL: 'keep',
LOWER_COL: 'KEEP',
LABEL_COL: 'Keep Me',
SOFT_FORMULA_COL: '=upper(KEY)',
READONLY_COL: 'locked'
})
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
hot?.destroy()
container.remove()
})
it('commits a typed value in the case the column rule requires', () => {
buildGrid([row()])
hot.setDataAtCell(0, 1, 'typed lower', 'edit')
hot.setDataAtCell(0, 2, 'TYPED UPPER', 'edit')
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('TYPED LOWER')
expect(hot.getDataAtRowProp(0, 'LOWER_COL')).toEqual('typed upper')
})
it('converts every cell of a pasted range', () => {
buildGrid([row(), row()])
hot.populateFromArray(
0,
1,
[
['one', 'ONE'],
['two', 'TWO']
],
1,
2,
'paste'
)
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('ONE')
expect(hot.getDataAtRowProp(1, 'UPPER_COL')).toEqual('TWO')
expect(hot.getDataAtRowProp(0, 'LOWER_COL')).toEqual('one')
expect(hot.getDataAtRowProp(1, 'LOWER_COL')).toEqual('two')
})
it('converts a value filled down into the cells below', () => {
buildGrid([row(), row(), row()])
hot.populateFromArray(0, 1, [['seed']], 2, 1, 'autofill')
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('SEED')
expect(hot.getDataAtRowProp(1, 'UPPER_COL')).toEqual('SEED')
expect(hot.getDataAtRowProp(2, 'UPPER_COL')).toEqual('SEED')
})
it('leaves the case of the dataset the grid is handed on load alone', () => {
const data = [row()]
buildGrid(data)
// the service's own values, before the user has touched anything - a
// conversion here would rewrite backend data and mark the row modified
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('keep')
expect(hot.getDataAtRowProp(0, 'LOWER_COL')).toEqual('KEEP')
// the same holds for a later bulk data update (add-row flow, Excel preview)
hot.updateSettings({ data: [row()] })
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('keep')
expect(hot.getDataAtRowProp(0, 'LOWER_COL')).toEqual('KEEP')
})
it('leaves a column without a CASE rule, and a formula column, exactly as entered', () => {
buildGrid([row()])
hot.setDataAtCell(0, 3, 'Mixed Case Label', 'edit')
hot.setDataAtCell(0, 4, '=UPPER(KEY)', 'edit')
expect(hot.getDataAtRowProp(0, 'LABEL_COL')).toEqual('Mixed Case Label')
expect(hot.getDataAtRowProp(0, 'SOFT_FORMULA_COL')).toEqual('=UPPER(KEY)')
})
it('undo restores the value the cell held before the converted entry', () => {
buildGrid([row()])
hot.setDataAtCell(0, 1, 'replaced', 'edit')
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('REPLACED')
hot.getPlugin('undoRedo').undo()
// exactly what was there - the restore takes the 'UndoRedo.undo' source,
// which is not user entry and is therefore not converted. A value from the
// service that did not satisfy the rule comes back as it arrived, not
// silently re-cased into one that does.
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('keep')
})
it('redo re-applies the converted value', () => {
buildGrid([row()])
hot.setDataAtCell(0, 1, 'replaced', 'edit')
hot.getPlugin('undoRedo').undo()
hot.getPlugin('undoRedo').redo()
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('REPLACED')
})
it('leaves the value Revert puts back exactly as the service supplied it', () => {
buildGrid([row()])
hot.setDataAtCell(0, 1, 'typed', 'edit')
// the Revert menu item writes the service's own value with this source -
// converting it would leave the cell modified against the unchanged
// baseline, so the revert would not have reverted
hot.setDataAtCell(0, 1, 'keep', 'revert')
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('keep')
})
it('leaves a value the pk-formula resolver writes back alone', () => {
buildGrid([row()])
// resolvePkFormula re-writes a computed key into its own cell on every row
// event. The key has to keep matching the backend, so it is not converted -
// and converting it would have the pass fighting itself.
hot.setDataAtCell(0, 1, 'mixedKey', 'resolvePkFormula')
expect(hot.getDataAtRowProp(0, 'UPPER_COL')).toEqual('mixedKey')
})
it('leaves a read-only column as entered, through the same predicate', () => {
buildGrid([row()])
// the grid normally refuses the edit; this writes through the source a
// programmatic change uses, so what is being asserted is the predicate the
// hook calls and not the grid's own guard
hot.setDataAtCell(0, 5, 'mixedCase', 'edit')
expect(hot.getDataAtRowProp(0, 'READONLY_COL')).toEqual('mixedCase')
})
describe('header marker', () => {
const buildHeaderGrid = () => {
const headerHot = new Handsontable(container, {
data: [row()],
columns: columnNames.map((name) => ({ data: name })),
// the hook under test only fires for a rendered column header
colHeaders: true,
licenseKey: 'non-commercial-and-evaluation'
})
// mirrors editor.component.ts's afterGetColHeader branch
headerHot.addHook('afterGetColHeader', (col: number, th: any) => {
const transform = caseRules[columnNames[col]]
if (transform) th.title = composeCaseHeaderTitle(th.title, transform)
})
headerHot.render()
return headerHot
}
it('carries one hint per header however often the grid re-renders', () => {
hot = buildHeaderGrid()
// the th element is reused across renders, which is how appending the
// hint each time grew the tooltip
hot.render()
hot.setDataAtCell(0, 1, 'x', 'edit')
hot.render()
// the grid paints more than one copy of a header (the master table and
// its clone), so the unit under test is the th element, not the document
const headers = Array.from(container.querySelectorAll('th'))
const hinted = headers.filter((th) =>
(th.title || '').includes(CASE_HEADER_HINTS.upper)
)
expect(hinted.length).toBeGreaterThan(0)
for (const th of hinted) {
expect(th.title.split(CASE_HEADER_HINTS.upper).length - 1).toEqual(1)
}
})
})
})
@@ -1,134 +0,0 @@
import Handsontable from 'handsontable'
import { HyperFormula } from 'hyperformula'
import {
observeGridContainerResize,
refreshGridAfterContainerResize
} from './refreshGridAfterContainerResize'
/**
* The editor refreshes its grid when the container changes size (sidebar
* toggle, modal open/close). That path must not touch grid settings: any
* `updateSettings()` while a multi-column sort is active corrupts formula
* references into `#REF!` (the bug `updateSettingsSortSafe()` works around),
* and its workaround - clear the sort, apply, re-apply - re-sorts the data
* underneath an open cell editor.
*
* This pins that contract against a real Handsontable instance, using the same
* technique as the other editor integration specs (editor.component.ts has no
* TestBed harness).
*/
describe('refreshGridAfterContainerResize', () => {
let container: HTMLDivElement
let hot: Handsontable
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
hot = new Handsontable(container, {
data: [
[3, 10, '=A1*B1'],
[1, 20, '=A2*B2'],
[2, 30, '=A3*B3']
],
columns: [{ data: 0 }, { data: 1 }, { data: 2, readOnly: true }],
formulas: { engine: HyperFormula, licenseKey: 'gpl-v3' },
multiColumnSorting: true,
licenseKey: 'non-commercial-and-evaluation'
})
})
afterEach(() => {
if (hot && !hot.isDestroyed) hot.destroy()
hot = undefined as any
container.remove()
})
it('re-measures without changing settings, so an active sort survives', () => {
const sorting = hot.getPlugin('multiColumnSorting') as any
sorting.sort([{ column: 0, sortOrder: 'asc' }])
const sortBefore = JSON.parse(JSON.stringify(sorting.getSortConfig()))
const updateSettings = spyOn(hot, 'updateSettings').and.callThrough()
const clearSort = spyOn(sorting, 'clearSort').and.callThrough()
const sort = spyOn(sorting, 'sort').and.callThrough()
const refreshDimensions = spyOn(hot, 'refreshDimensions').and.callThrough()
refreshGridAfterContainerResize(hot)
expect(refreshDimensions).toHaveBeenCalledTimes(1)
expect(updateSettings).not.toHaveBeenCalled()
expect(clearSort).not.toHaveBeenCalled()
expect(sort).not.toHaveBeenCalled()
expect(JSON.parse(JSON.stringify(sorting.getSortConfig()))).toEqual(
sortBefore
)
})
it('leaves formula values intact across a resize', () => {
const valuesBefore = hot.getData()
refreshGridAfterContainerResize(hot)
expect(hot.getData()).toEqual(valuesBefore)
const flat = (hot.getData() as any[][]).flat()
expect(flat.some((value) => String(value).includes('#REF'))).toBeFalse()
})
it('ignores a missing or destroyed instance', () => {
expect(() => refreshGridAfterContainerResize(null)).not.toThrow()
expect(() => refreshGridAfterContainerResize(undefined)).not.toThrow()
hot.destroy()
expect(() => refreshGridAfterContainerResize(hot)).not.toThrow()
})
})
/**
* The observer is what makes the helper fire, and it is the wiring - not the
* helper body - that regressed into `updateSettings()` in the first place. A
* helper-only spec passes even if the observer reapplies settings, so this one
* drives a real container resize through the observer and fails on any
* settings call.
*/
describe('observeGridContainerResize', () => {
it('re-measures on a container resize without touching grid settings', async () => {
const container = document.createElement('div')
container.style.width = '500px'
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [
[3, 10, '=A1*B1'],
[1, 20, '=A2*B2']
],
colHeaders: true,
width: '100%',
height: 300,
licenseKey: undefined
})
const settingsSpy = spyOn(hot, 'updateSettings').and.callThrough()
const refreshSpy = spyOn(hot, 'refreshDimensions').and.callThrough()
const observer = observeGridContainerResize(hot, container)
expect(observer).toBeDefined()
container.style.width = '320px'
await new Promise((resolve) => setTimeout(resolve, 400))
expect(refreshSpy).toHaveBeenCalled()
expect(settingsSpy).not.toHaveBeenCalled()
observer?.disconnect()
hot.destroy()
container.remove()
})
it('returns nothing when the grid or container is missing', () => {
const container = document.createElement('div')
expect(observeGridContainerResize(null, container)).toBeUndefined()
expect(observeGridContainerResize(undefined, undefined)).toBeUndefined()
})
})
@@ -1,53 +0,0 @@
import Handsontable from 'handsontable'
/**
* Re-measures a grid after its container changed size (sidebar toggle, modal
* open/close, window resize).
*
* Deliberately does NOT call `updateSettings()`. Any settings call while a
* multi-column sort is active corrupts formula cell references into `#REF!` -
* that is the bug `updateSettingsSortSafe()` exists for - and the workaround
* (clear the sort, apply, re-apply the sort) re-sorts the data underneath an
* open cell editor, so typing can land on a different row than the one being
* edited. A container resize has no business changing settings at all:
* `refreshDimensions()` measures `rootElement.getBoundingClientRect()` and
* compares it with the last measured size, re-running `adjustElementsSize()`
* and a render only when the box actually moved. Both grids take their height
* from a viewport-derived `calc()` setting (editor.component.ts:203), so a
* container resize that matters to the grid is a width change or a viewport
* change - both of which move that box. It also fires
* `beforeRefreshDimensions` / `afterRefreshDimensions` for anything that needs
* to react.
*/
export function refreshGridAfterContainerResize(
hot: Handsontable | null | undefined
): void {
if (!hot || hot.isDestroyed) return
hot.refreshDimensions()
}
/**
* Watches a grid's container and re-measures the grid whenever its box changes
* (sidebar toggle, modal open/close, window resize).
*
* The observer lives here rather than in the component so the wiring is
* covered by a spec: what matters is not that `refreshDimensions()` is called
* but that a resize never reaches `updateSettings()`.
*
* Returns the observer so the caller can disconnect it on destroy.
*/
export function observeGridContainerResize(
hot: Handsontable | null | undefined,
container: HTMLElement | null | undefined
): ResizeObserver | undefined {
if (!hot || !container || typeof ResizeObserver === 'undefined') {
return undefined
}
const observer = new ResizeObserver(() => {
requestAnimationFrame(() => refreshGridAfterContainerResize(hot))
})
observer.observe(container)
return observer
}
@@ -1,140 +0,0 @@
@if (startupDataLoaded) {
@if (licenceProblem && !freeTier) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">Problem with the licence key.</div>
</div>
</div>
<a (click)="openProblem(licenceProblem)" class="update-key cursor-pointer"
>More details</a
>
</div>
} @else if (freeTier) {
<div
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
@if (licenceProblem === null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - to upgrade contact
<contact-link classes="color-white" />
</div>
</div>
</div>
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
}
@if (licenceProblem !== null) {
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
Data Controller (FREE Tier) - Problem with licence
</div>
</div>
</div>
<a
(click)="openProblem(licenceProblem)"
class="update-key cursor-pointer"
>More details</a
>
}
</div>
} @else if (overCapacity) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
The registered number of users exceeds the limit specified for your
license. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ siteId }}.
</div>
</div>
</div>
<a routerLink="/licensing/limit" class="update-key">Licence details</a>
</div>
} @else if (appActive === false) {
<div
class="alert alert-app-level alert-warning"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
@if (canRegister) {
<div class="alert-text">
Data Controller is not registered for this user. Accept the terms
to register and use your licence on this site.
</div>
} @else {
<div class="alert-text">
The registered number of users has reached the limit for this
licence. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ siteId }}.
</div>
}
</div>
</div>
@if (canRegister) {
<a routerLink="/licensing/register" class="update-key">Register</a>
} @else {
<a routerLink="/licensing/limit" class="update-key">Licence details</a>
}
</div>
} @else if (expiringDays) {
<div
class="alert alert-app-level alert-danger"
id="demo-banner"
role="alert"
>
<div class="alert-items">
<div class="alert-item static">
<div class="alert-icon-wrapper">
<cds-icon class="alert-icon" shape="warning-standard"></cds-icon>
</div>
<div class="alert-text">
This license key will expire in {{ expiringDays }}
{{ expiringDays === 1 ? 'day' : 'days' }}. Please contact
<contact-link classes="color-white" />
or your reseller to arrange additional licence for site id
{{ siteId }}.
</div>
</div>
</div>
<a routerLink="/licensing/update" class="update-key"
>Update Licence Key</a
>
</div>
}
}
@@ -1,161 +0,0 @@
import { NO_ERRORS_SCHEMA } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { provideRouter, RouterModule } from '@angular/router'
import { LicenceBannerComponent } from './licence-banner.component'
/**
* The banner is the only route into the licensing pages: each state has to
* render, and each has to point at the page that can actually fix it.
*/
describe('LicenceBannerComponent', () => {
let fixture: ComponentFixture<LicenceBannerComponent>
let component: LicenceBannerComponent
const render = (inputs: Partial<LicenceBannerComponent>) => {
fixture = TestBed.createComponent(LicenceBannerComponent)
component = fixture.componentInstance
component.startupDataLoaded = true
component.appActive = true
Object.assign(component, inputs)
fixture.detectChanges()
return fixture.nativeElement as HTMLElement
}
const link = (el: HTMLElement) =>
el.querySelector('a.update-key') as HTMLAnchorElement
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [LicenceBannerComponent],
imports: [RouterModule],
providers: [provideRouter([])],
schemas: [NO_ERRORS_SCHEMA]
})
})
it('reports a licence problem from a page that is not free tier', () => {
const el = render({
freeTier: false,
licenceProblem: '/licensing/key?error=invalid'
})
expect(el.querySelector('#demo-banner')?.textContent).toContain(
'Problem with the licence key'
)
expect(link(el).textContent).toContain('More details')
})
it('emits the encoded problem url when more details is clicked', () => {
const url = '/licensing/key?error=expired&details=abc'
const el = render({ licenceProblem: url })
let emitted = ''
component.problemDetails.subscribe((value: string) => (emitted = value))
link(el).click()
expect(emitted).toBe(url)
})
it('offers the update key page on free tier', () => {
const el = render({ freeTier: true, licenceProblem: null })
expect(el.querySelector('#demo-banner')?.textContent).toContain('FREE Tier')
expect(link(el).textContent).toContain('Update Licence Key')
expect(link(el).getAttribute('href')).toBe('/licensing/update')
})
it('offers the problem details on free tier with a problem', () => {
const el = render({
freeTier: true,
licenceProblem: '/licensing/key?error=missmatch'
})
expect(el.querySelector('#demo-banner')?.textContent).toContain(
'Problem with licence'
)
expect(link(el).textContent).toContain('More details')
})
it('offers the limit page when the user count is exceeded', () => {
const el = render({ overCapacity: true })
expect(el.querySelector('#demo-banner')?.textContent).toContain(
'exceeds the limit'
)
expect(link(el).getAttribute('href')).toBe('/licensing/limit')
})
it('offers registration to an unregistered user', () => {
const el = render({ appActive: false, canRegister: true })
expect(el.querySelector('#demo-banner')?.textContent).toContain(
'not registered for this user'
)
expect(link(el).textContent).toContain('Register')
expect(link(el).getAttribute('href')).toBe('/licensing/register')
})
it('offers the limit page to an unregistered user who cannot register', () => {
const el = render({ appActive: false, canRegister: false })
expect(el.querySelector('#demo-banner')?.textContent).toContain(
'reached the limit'
)
expect(link(el).getAttribute('href')).toBe('/licensing/limit')
})
it('reports a licence problem instead of registration for the same user', () => {
const el = render({
appActive: false,
canRegister: true,
licenceProblem: '/licensing/key?error=invalid&details=abc'
})
expect(el.querySelector('#demo-banner')?.textContent).toContain(
'Problem with the licence key'
)
expect(el.querySelector('#demo-banner')?.textContent).not.toContain(
'not registered'
)
})
it('reports over capacity instead of registration for the same user', () => {
const el = render({
appActive: false,
canRegister: true,
overCapacity: true
})
expect(el.querySelector('#demo-banner')?.textContent).toContain(
'exceeds the limit'
)
expect(el.querySelector('#demo-banner')?.textContent).not.toContain(
'not registered'
)
})
it('offers the update key page inside the expiry window', () => {
const el = render({ expiringDays: 10 })
expect(el.querySelector('#demo-banner')?.textContent).toContain(
'will expire in 10 days'
)
expect(link(el).getAttribute('href')).toBe('/licensing/update')
})
it('renders nothing before the startup data lands', () => {
const el = render({ startupDataLoaded: false, appActive: false })
expect(el.querySelector('#demo-banner')).toBeNull()
})
it('renders nothing when the licence is in order', () => {
const el = render({})
expect(el.querySelector('#demo-banner')).toBeNull()
})
})
@@ -1,29 +0,0 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
/**
* The licence banner is the entry point to the licensing pages: the app never
* moves the user there. One state renders at a time, in the order below, so the
* message the user sees is the one that blocks them most.
*/
@Component({
selector: 'app-licence-banner',
templateUrl: './licence-banner.component.html',
standalone: false
})
export class LicenceBannerComponent {
@Input() public startupDataLoaded = false
@Input() public appActive: boolean | null = null
@Input() public freeTier = false
@Input() public expiringDays: number | null = null
@Input() public overCapacity = false
@Input() public licenceProblem: string | null = null
@Input() public canRegister = true
@Input() public siteId: string | undefined = ''
/** Emits the encoded licensing URL for the problem details page. */
@Output() public problemDetails = new EventEmitter<string>()
public openProblem(url: string | null) {
if (url) this.problemDetails.emit(url)
}
}
@@ -1,11 +1,14 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { LicensingGuard } from '../routes/licensing.guard'
import { LicensingComponent } from './licensing.component'
const routes: Routes = [
{
path: ':action',
component: LicensingComponent
component: LicensingComponent,
canActivate: [LicensingGuard],
canDeactivate: [LicensingGuard]
}
]
@@ -1,6 +1,5 @@
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { combineLatest, Subscription } from 'rxjs'
import { ActivatedRoute, Router } from '@angular/router'
import { AppService, LicenceService, SasService } from '../services'
import { LicenseKeyData } from '../models/LicenseKeyData'
@@ -29,11 +28,9 @@ enum LicenseActions {
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class LicensingComponent implements OnInit, OnDestroy {
export class LicensingComponent implements OnInit {
public action: LicenseActions | null = null
private licensingRouteSubscription?: Subscription
// Each message carries its own icon markup, rather than relying on a
// sibling element in the template - these render via [innerHTML], which
// replaces all of the host element's children, so a real <clr-icon>
@@ -117,42 +114,19 @@ export class LicensingComponent implements OnInit, OnDestroy {
}
})
// The action comes from the URL and the licence state from the startup
// flow, so one subscription watches both. That covers a licence state that
// lands after the page loads, and a switch to another licensing action
// without a reload. An unknown action has no page to render, so it goes
// home; so does the registration page on an install that is already
// registered, where accepting the EULA would post public/registeruser and
// abort for an already-registered user.
this.licensingRouteSubscription = combineLatest([
this.route.params,
this.licenceService.isAppActivated
]).subscribe(([params, activated]: [any, boolean | null]) => {
const actionInUrl = params.action
this.route.params.subscribe((params: any) => {
let actionInUrl = params.action
if (actionInUrl && !Object.values(LicenseActions).includes(actionInUrl)) {
// Clear the action first: the component instance is reused when only
// the :action segment changes, so the previous page would stay on
// screen until the navigation lands.
this.action = null
this.router.navigateByUrl('/home')
return
}
this.action = actionInUrl || null
if (activated === true && this.action === 'register') {
this.router.navigateByUrl('/home')
if (actionInUrl) {
if (Object.values(LicenseActions).includes(actionInUrl)) {
this.action = actionInUrl
}
}
})
this.licenseKeyData = this.licenceService.getLicenseKeyData()
}
ngOnDestroy(): void {
this.licensingRouteSubscription?.unsubscribe()
}
public async trimKeys() {
this.licenceKeyValue = this.licenceKeyValue.trim()
this.activationKeyValue = this.activationKeyValue.trim()
-5
View File
@@ -51,11 +51,6 @@ export interface SASParam {
TRUNCATED?: number
/** Reason shown in the tooltip when TRUNCATED is 1, else empty */
TRUNC_MSG?: string
/**
* Space separated list of the derived (computed) columns from MPE_DERIVED,
* upper case. Empty when the table has none.
*/
DERIVEDVARS?: string
}
export interface ExcelRule {
@@ -1,48 +0,0 @@
/**
* Shapes returned by the makedata service's filesystem actions.
*
* The service answers two questions the deploy screens ask before the control
* library exists: what is below this folder (`ACTION=DIRLIST`) and can we write
* to this folder (`ACTION=WRITECHECK`). Fields arrive with SAS blank padding,
* so every consumer trims before it tests.
*/
export interface DcDirInfo {
PARENT: string
SEP: string
TRUNCATED: string
PLATFORM: string
DEPTH: number
}
export interface DcFolderRow {
FILEPATH: string
FILENAME: string
FILE_OR_FOLDER: string
LEVEL: number
MSG: string
}
export interface DcFolderEntry {
filepath: string
filename: string
level: number
info: string
/** an entry mp_dirlist listed but could not open - shown, not usable */
locked: boolean
}
export interface DcWriteCheckRow {
PATH: string
PATH_EXISTS: string
IS_FOLDER: string
WRITEABLE: string
MSG: string
}
export interface DcWriteCheckResult {
path: string
exists: boolean
isFolder: boolean
writeable: boolean
msg: string
}
@@ -157,16 +157,6 @@
<p cds-text="secondary regular" class="mb-20">
File size: <strong>{{ selectedFile.sizeMB }} MB</strong>
</p>
<p
cds-text="secondary regular"
class="mb-20 d-flex clr-align-items-center"
id="file-loading-stage"
>
Stage: <strong class="ml-5">{{ fileLoadingState }}</strong>
@if (isFileBusy) {
<clr-spinner class="ml-10" clrSmall></clr-spinner>
}
</p>
<p cds-text="secondary regular" class="mb-15">
Paste or type the list of datasets to upload:
</p>
@@ -198,26 +188,18 @@
@if (parsedDatasets.length && !submittedCsvDatasets.length) {
@if (!activeParsedDataset) {
<div class="no-table-selected pointer-events-none">
@if (isFileBusy) {
@if (fileLoadingState !== FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="process-on-vm"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p
class="text-center color-gray mt-10"
cds-text="section"
id="file-extraction-stage"
>
<p class="text-center color-gray mt-10" cds-text="section">
{{ fileLoadingState }}...
</p>
<p class="text-center color-gray" cds-text="secondary regular">
{{ selectedFile?.name }} - {{ selectedFile?.sizeMB }} MB
</p>
<clr-spinner class="mt-10" clrSmall></clr-spinner>
}
@if (!isFileBusy) {
@if (fileLoadingState === FileLoadingState.parsed) {
<clr-icon
aria-hidden="true"
shape="warning-standard"
@@ -227,32 +209,6 @@
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left to review the data
</p>
@if (sheetsNotExtracted.length) {
<p
id="file-extraction-failures"
cds-text="secondary regular"
class="mt-15 color-red"
>
Could not extract
<strong class="ml-5">{{
sheetsNotExtracted.join(', ')
}}</strong
>. Those tables are left out of the submission.
</p>
}
@if (sheetsWithoutRows.length) {
<p
id="file-empty-sheets"
cds-text="secondary regular"
class="mt-15"
>
No rows found in
<strong class="ml-5">{{
sheetsWithoutRows.join(', ')
}}</strong
>. Those tables are left out of the submission.
</p>
}
}
</div>
}
@@ -1,192 +0,0 @@
import { ChangeDetectorRef } from '@angular/core'
import { TestBed } from '@angular/core/testing'
import {
EventService,
HelperService,
LicenceService,
SasService,
SasStoreService
} from '../services'
import { SpreadsheetService } from '../services/spreadsheet.service'
import { MultiDatasetComponent } from './multi-dataset.component'
const DATASET = (libds: string): any => ({
libds,
includeInSubmission: true,
parsingTable: true,
datasetInfo: {
libds,
dcValidator: {},
headerPks: [],
headerArray: [],
timeHeaders: [],
dateHeaders: [],
dateTimeHeaders: [],
xlRules: {}
}
})
describe('MultiDatasetComponent extraction', () => {
let component: MultiDatasetComponent
/**
* Built through the injector rather than with positional arguments, so an
* added or reordered constructor parameter fails here instead of binding a
* stub to the wrong collaborator.
*/
const buildComponent = (parseExcelFile: any): MultiDatasetComponent => {
TestBed.configureTestingModule({
providers: [
MultiDatasetComponent,
{ provide: EventService, useValue: {} },
{ provide: LicenceService, useValue: { licenceState: { value: {} } } },
{ provide: HelperService, useValue: {} },
{ provide: SasStoreService, useValue: {} },
{ provide: SpreadsheetService, useValue: { parseExcelFile } },
{ provide: SasService, useValue: {} },
{ provide: ChangeDetectorRef, useValue: {} }
]
})
return TestBed.inject(MultiDatasetComponent)
}
const parseResult = {
data: [
[10, 'first'],
[20, 'second']
],
headerShow: ['age', 'name']
}
afterEach(() => TestBed.resetTestingModule())
describe('parseDatasetSheet', () => {
it('fills the dataset from the parsed sheet', async () => {
component = buildComponent(() => Promise.resolve(parseResult))
component.selectedFile = { name: 'file.xlsx' } as any
const dataset = DATASET('WORK.FIRST')
await component.parseDatasetSheet(dataset, {} as any)
expect(dataset.datasource).toEqual([
{
age: 10,
name: 'first',
_____DELETE__THIS__RECORD_____: 'No'
},
{
age: 20,
name: 'second',
_____DELETE__THIS__RECORD_____: 'No'
}
])
expect(dataset.parsingTable).toBeFalse()
expect(component.sheetsNotExtracted).toEqual([])
expect(component.sheetsWithoutRows).toEqual([])
})
it('drops a sheet that could not be parsed and names it', async () => {
component = buildComponent(() => Promise.reject('sheet is corrupt'))
component.selectedFile = { name: 'file.xlsx' } as any
const dataset = DATASET('WORK.BROKEN')
await component.parseDatasetSheet(dataset, {} as any)
expect(dataset.datasource).toEqual([])
expect(dataset.includeInSubmission).toBeFalse()
expect(dataset.parsingTable).toBeFalse()
expect(component.sheetsNotExtracted).toEqual(['WORK.BROKEN'])
expect(component.sheetsWithoutRows).toEqual([])
})
it('names a sheet that resolved without rows as empty rather than unreadable', async () => {
component = buildComponent(() => Promise.resolve(undefined))
component.selectedFile = { name: 'file.xlsx' } as any
const dataset = DATASET('WORK.EMPTY')
await component.parseDatasetSheet(dataset, {} as any)
expect(dataset.datasource).toEqual([])
expect(dataset.includeInSubmission).toBeFalse()
expect(dataset.parsingTable).toBeFalse()
expect(component.sheetsWithoutRows).toEqual(['WORK.EMPTY'])
expect(component.sheetsNotExtracted).toEqual([])
})
it('names a sheet the utility reports as holding no data as empty', async () => {
component = buildComponent(() =>
Promise.reject('No relevant data found in File !')
)
component.selectedFile = { name: 'file.xlsx' } as any
const dataset = DATASET('WORK.NODATA')
await component.parseDatasetSheet(dataset, {} as any)
expect(dataset.includeInSubmission).toBeFalse()
expect(component.sheetsWithoutRows).toEqual(['WORK.NODATA'])
expect(component.sheetsNotExtracted).toEqual([])
})
it('reports a sheet whose rows could not be derived as a failure', async () => {
component = buildComponent(() =>
Promise.resolve({ data: [[10, 'first']], headerShow: undefined })
)
component.selectedFile = { name: 'file.xlsx' } as any
const dataset = DATASET('WORK.MALFORMED')
await component.parseDatasetSheet(dataset, {} as any)
expect(dataset.datasource).toEqual([])
expect(dataset.includeInSubmission).toBeFalse()
expect(dataset.parsingTable).toBeFalse()
expect(component.sheetsNotExtracted).toEqual(['WORK.MALFORMED'])
})
it('treats a parse that resolves with an empty data array as a sheet with no rows', async () => {
component = buildComponent(() =>
Promise.resolve({ data: [], headerShow: [] })
)
component.selectedFile = { name: 'file.xlsx' } as any
const dataset = DATASET('WORK.EMPTY')
await component.parseDatasetSheet(dataset, {} as any)
expect(dataset.includeInSubmission).toBeFalse()
expect(component.sheetsWithoutRows).toEqual(['WORK.EMPTY'])
})
})
describe('finishExtraction', () => {
it('does not report a clean finish when a sheet could not be read', () => {
component = buildComponent(() => Promise.resolve(parseResult))
component.sheetsNotExtracted = ['WORK.BROKEN']
component.finishExtraction()
expect(component.fileLoadingState).toEqual(
'Extracting the data finished with a problem'
)
expect(component.isFileBusy).toBeFalse()
})
it('does not report a clean finish when a sheet was empty', () => {
component = buildComponent(() => Promise.resolve(parseResult))
component.sheetsWithoutRows = ['WORK.EMPTY']
component.finishExtraction()
expect(component.fileLoadingState).toEqual(
'Extracting the data finished with a problem'
)
})
it('reports the finish it had when every sheet was extracted', () => {
component = buildComponent(() => Promise.resolve(parseResult))
component.finishExtraction()
expect(component.fileLoadingState).toEqual('Extracting the data finished')
})
})
})
@@ -37,23 +37,13 @@ import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'
import XLSX from 'xlsx'
import { observeGridAria } from '../shared/utils/grid-aria'
/** The sheet utility's rejection for a sheet that parsed but holds no rows. */
const isEmptySheet = (error: unknown): boolean =>
typeof error === 'string' && error.startsWith('No relevant data found')
enum FileLoadingState {
reading = 'Reading the file',
scanning = 'Searching for the data in the file',
ready = 'File ready to load',
fetching = 'Fetching the dataset details',
extracting = 'Extracting the data',
extracted = 'Extracting the data finished',
/** Some sheets could not be extracted, so the file is not fully loaded. */
extractionFailed = 'Extracting the data finished with a problem',
parsing = 'Searching for the data in the file',
parsed = 'Searching for the data finished',
/**
* Default value
* Defualt value
*/
notSelected = 'File not selected'
}
@@ -74,24 +64,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
public workbookInterval: any
public fileLoadingState: FileLoadingState = FileLoadingState.notSelected
/** The sheets the last extraction could not read, so the stage can name them. */
public sheetsNotExtracted: string[] = []
/** The sheets the last extraction read but found no rows in. */
public sheetsWithoutRows: string[] = []
/**
* True while the file is being worked on. `ready`, `extracted` and
* `extractionFailed` are idle states, so the spinner and the stage line are
* only shown for the phases that actually have work running.
*/
public get isFileBusy(): boolean {
return (
this.fileLoadingState === FileLoadingState.reading ||
this.fileLoadingState === FileLoadingState.scanning ||
this.fileLoadingState === FileLoadingState.fetching ||
this.fileLoadingState === FileLoadingState.extracting
)
}
public FileLoadingState = FileLoadingState
public hotTableLicenseKey: string | undefined = undefined
public hotTableMaxRows =
@@ -301,8 +275,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
if (this.selectedFile) {
this.fileLoadingState = FileLoadingState.reading
this.sheetsNotExtracted = []
this.sheetsWithoutRows = []
this.selectedFile.sizeMB = this.spreadsheetService.bytesToMB(
this.selectedFile.size
@@ -310,13 +282,8 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
// Read the excel file to be ready
this.spreadsheetService.xlsxReadFile(this.selectedFile!).then((wb) => {
this.fileLoadingState = FileLoadingState.parsing
this.selectedFile!.workbook = wb
// The sheet scan runs in parallel and can finish first, so only
// advance the stage if the read is still the phase in progress.
if (this.fileLoadingState === FileLoadingState.reading) {
this.fileLoadingState = FileLoadingState.scanning
}
})
}
@@ -373,12 +340,10 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
onDiscardFile() {
this.selectedFile = null
this.userInputDatasets = ''
this.fileLoadingState = FileLoadingState.notSelected
}
async onStartParsingFile() {
this.uploadLoading = true
this.fileLoadingState = FileLoadingState.fetching
const datasetFetchingPromises: Promise<
EditorsGetDataServiceResponse | undefined
@@ -412,7 +377,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
datasetObjects = this.buildDatasetsObjects(datasets)
} catch (err) {
console.warn('Error building dataset objects', err)
this.fileLoadingState = FileLoadingState.ready
this.eventService.showInfoModal('Upload error', (err as Error).message)
return
}
@@ -427,135 +391,58 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
})
this.workbookLoaded().then((workbook) => {
this.fileLoadingState = FileLoadingState.extracting
this.sheetsNotExtracted = []
this.sheetsWithoutRows = []
const parsingPromises: Promise<void>[] = []
for (let parsedDataset of this.parsedDatasets) {
parsingPromises.push(this.parseDatasetSheet(parsedDataset, workbook))
this.spreadsheetService
.parseExcelFile({
file: this.selectedFile!,
workbook: workbook,
password: this.selectedFile!.password || undefined,
dcValidator: parsedDataset.datasetInfo.dcValidator!,
headerPks: parsedDataset.datasetInfo.headerPks,
headerArray: parsedDataset.datasetInfo.headerArray,
headerShow: [],
timeHeaders: parsedDataset.datasetInfo.timeHeaders,
dateHeaders: parsedDataset.datasetInfo.dateHeaders,
dateTimeHeaders: parsedDataset.datasetInfo.dateTimeHeaders,
xlRules: parsedDataset.datasetInfo.xlRules
})
.then((parseResult: ParseResult | undefined) => {
this.fileLoadingState = FileLoadingState.parsed
if (parseResult && parseResult.data) {
let datasource: any[] = []
parseResult.data.map((item) => {
let itemObject: any = {}
parseResult.headerShow!.map((header: any, index: number) => {
itemObject[header] = item[index]
})
// If Delete? column is not set in the file, we set it to NO
if (!itemObject['_____DELETE__THIS__RECORD_____'])
itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
datasource.push(itemObject)
})
parsedDataset.datasource = datasource
parsedDataset.parseResult = parseResult
parsedDataset.parsingTable = false
}
})
.catch((error: string) => {
console.warn('Parsing excel file error.', error)
parsedDataset.datasource = []
parsedDataset.includeInSubmission = false
parsedDataset.parsingTable = false
})
}
// The stage belongs to the whole file, not to the sheet that finished
// last, so it only moves on once every sheet has been extracted - and a
// sheet that could not be extracted is not a clean finish.
Promise.allSettled(parsingPromises).then(() => this.finishExtraction())
})
})
}
/**
* The stage the file finishes at: a clean finish only when every sheet was
* extracted, and a named failure when one was left out of the submission.
*/
public finishExtraction(): void {
const leftOut =
this.sheetsNotExtracted.length > 0 || this.sheetsWithoutRows.length > 0
this.fileLoadingState = leftOut
? FileLoadingState.extractionFailed
: FileLoadingState.extracted
}
/**
* Takes a sheet out of the submission and names it, under the reason it left:
* a sheet that could not be read is not the same as one that was read and
* holds no rows, and the two read differently to the user.
*/
public leaveSheetOut(
parsedDataset: ParsedDataset,
reason: 'failed' | 'empty'
): void {
parsedDataset.datasource = []
parsedDataset.includeInSubmission = false
parsedDataset.parsingTable = false
const sheets =
reason === 'empty' ? this.sheetsWithoutRows : this.sheetsNotExtracted
sheets.push(parsedDataset.libds)
}
/**
* Extracts one sheet into its dataset. A sheet that cannot be extracted is
* left out of the submission and named, so the stage the file finishes at can
* say that it did not fully extract rather than reporting a clean finish over
* a sheet that was silently dropped.
*/
public async parseDatasetSheet(
parsedDataset: ParsedDataset,
workbook: XLSX.WorkBook
): Promise<void> {
let parseResult: ParseResult | undefined
try {
parseResult = await this.spreadsheetService.parseExcelFile({
file: this.selectedFile!,
workbook: workbook,
password: this.selectedFile!.password || undefined,
dcValidator: parsedDataset.datasetInfo.dcValidator!,
headerPks: parsedDataset.datasetInfo.headerPks,
headerArray: parsedDataset.datasetInfo.headerArray,
headerShow: [],
timeHeaders: parsedDataset.datasetInfo.timeHeaders,
dateHeaders: parsedDataset.datasetInfo.dateHeaders,
dateTimeHeaders: parsedDataset.datasetInfo.dateTimeHeaders,
xlRules: parsedDataset.datasetInfo.xlRules
})
// A resolve carrying no rows did extract - it just holds nothing - so it
// is named as empty rather than as unreadable, and the stage still does
// not call the file cleanly loaded.
if (!parseResult || !parseResult.data || parseResult.data.length === 0) {
this.leaveSheetOut(parsedDataset, 'empty')
return
}
// The derivation is inside the try on purpose: a row shape the sheet
// utility did not expect throws here, and outside it the throw would
// escape as a rejection that allSettled swallows - leaving the sheet in
// the submission with a spinner and a clean finish reported.
parsedDataset.datasource = this.buildDatasource(parseResult)
parsedDataset.parseResult = parseResult
parsedDataset.parsingTable = false
} catch (error) {
console.warn('Parsing excel file error.', error)
// The sheet utility reports a sheet that parsed to nothing by rejecting
// with "No relevant data found", which is a different thing to a sheet it
// could not read - the reader is sent looking for a corrupt file by the
// second, and to the sheet itself by the first.
this.leaveSheetOut(
parsedDataset,
isEmptySheet(error) ? 'empty' : 'failed'
)
}
}
/** The rows the grid renders, built from a parsed sheet. */
private buildDatasource(parseResult: ParseResult): any[] {
const datasource: any[] = []
const rows = parseResult.data!
rows.map((item) => {
let itemObject: any = {}
parseResult.headerShow!.map((header: any, index: number) => {
itemObject[header] = item[index]
})
// If Delete? column is not set in the file, we set it to NO
if (!itemObject['_____DELETE__THIS__RECORD_____'])
itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
datasource.push(itemObject)
})
return datasource
}
onSubmitAll() {
if (this.tablesToSubmit.length) {
this.showSubmitReasonModal = true
@@ -573,14 +460,12 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
this.selectedFile = null
this.userInputDatasets = ''
this.submitReasonMessage = ''
this.fileLoadingState = FileLoadingState.notSelected
}
initHot() {
setTimeout(() => {
if (this.hotTableMainComponent?.hotInstance) {
this.hotInstance = this.hotTableMainComponent.hotInstance
observeGridAria(this.hotInstance)
// Set height of parsed data to full height of the page content area
const contentAreaHeight = this.contentAreaRef.nativeElement.clientHeight
@@ -620,7 +505,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
if (this.hotTableUserDatasetComponent?.hotInstance) {
this.hotInstanceUserDataset =
this.hotTableUserDatasetComponent.hotInstance
observeGridAria(this.hotInstanceUserDataset)
// Load initial data manually after instance is ready
setTimeout(() => {
@@ -790,7 +674,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
}
if (!this.hotInstanceUserDataset) {
console.warn('hotInstanceUserDataset not ready after waiting')
this.fileLoadingState = FileLoadingState.ready
return
}
}
@@ -863,10 +746,6 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
}, 100)
}
this.dynamicCellValidations()
// The sheet scan is done; the file waits for the user to confirm the
// dataset list, so no stage is running any more.
this.fileLoadingState = FileLoadingState.ready
}
onParsedDatasetClick(parsedDataset: ParsedDataset) {
+55
View File
@@ -0,0 +1,55 @@
import {
ActivatedRouteSnapshot,
RouterStateSnapshot,
Router
} from '@angular/router'
import { LicensingComponent } from '../licensing/licensing.component'
import { Injectable } from '@angular/core'
import { LicenceService } from '../services/licence.service'
@Injectable()
export class LicensingGuard {
constructor(
private licenceService: LicenceService,
private router: Router
) {}
canActivate(
currentRoute: ActivatedRouteSnapshot,
currentState: RouterStateSnapshot
): boolean {
if (currentState?.root.queryParams.force !== undefined) return true
if (currentState?.url.includes('licensing/update')) {
if (!!this.licenceService.isAppActivated) return true
}
if (
this.licenceService.isAppActivated.value !== null &&
this.licenceService.isAppActivated.value === false
) {
return true
} else {
this.router.navigateByUrl('/home')
return false
}
}
canDeactivate(
component: LicensingComponent,
currentRoute: ActivatedRouteSnapshot,
currentState: RouterStateSnapshot,
nextState?: RouterStateSnapshot
): boolean {
if (currentState?.url.includes('licensing/register')) {
return false
}
if (!!this.licenceService.appLocked.value) return false
return true
if (this.licenceService.isAppActivated.value === null) return true
return !!this.licenceService.isAppActivated.value
}
}
@@ -178,48 +178,3 @@ describe('AppService - startup retry', () => {
expect(deps.licenceService.isAppActivated.value).toBeFalse()
})
})
// Startup never moves the user. Licensing pages are ordinary pages: an
// activated app that loads one of their URLs directly (bookmark, browser
// reload, or a spec's cy.visit) stays there, and an unactivated app stays on
// whatever page it loaded - the banner in app.component is the entry point to
// the licensing page.
describe('AppService - licensing routes', () => {
it('leaves an activated app on a licensing URL instead of redirecting to /home', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo(validStartupResponse())
deps.licenceService.isAppActivated.next(true)
deps.router.url = '/#/licensing/update'
const appService = buildAppService(deps)
await appService.startUpData()
expect(deps.eventService.startupDataLoaded).toHaveBeenCalled()
expect(deps.router.navigateByUrl).not.toHaveBeenCalled()
})
it('leaves an unactivated app on the page it loaded', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo(validStartupResponse())
deps.licenceService.isAppActivated.next(false)
deps.router.url = '/#/home/tables'
const appService = buildAppService(deps)
await appService.startUpData()
expect(deps.eventService.startupDataLoaded).toHaveBeenCalled()
expect(deps.router.navigateByUrl).not.toHaveBeenCalled()
})
it('does not redirect an unactivated app that is already on a licensing page', async () => {
const deps = buildDeps()
deps.sasService.request.and.resolveTo(validStartupResponse())
deps.licenceService.isAppActivated.next(false)
deps.router.url = '/#/licensing/register'
const appService = buildAppService(deps)
await appService.startUpData()
expect(deps.router.navigateByUrl).not.toHaveBeenCalled()
})
})
+13
View File
@@ -239,6 +239,19 @@ export class AppService {
if (!startupServiceError) {
this.eventService.startupDataLoaded()
if (!this.licenceService.isAppActivated.value) {
// If route is already on `licensing` page then we won't re route
if (
!this.router.url.includes('deploy') &&
!this.router.url.includes('licensing')
) {
this.router.navigateByUrl('/licensing/key?error=missing&force=true')
}
} else {
if (this.router.url.includes('licensing'))
this.router.navigateByUrl('/home')
}
}
}
}
+18
View File
@@ -26,4 +26,22 @@ export class DeployService {
document.body.removeChild(downloadLink)
}
}
public readFile(file: File): Promise<any> {
return new Promise((resolve, reject) => {
let JsonFileReader = new FileReader()
JsonFileReader.onload = () => {
if (JsonFileReader.result) {
resolve(JSON.parse(JsonFileReader.result.toString()))
}
}
JsonFileReader.readAsText(file)
})
}
public clearUploadInput(event: Event) {
;(<HTMLInputElement>event.target).value = ''
}
}
+14 -3
View File
@@ -13,7 +13,7 @@ export class EventService {
@Output() onStartupDataLoaded: EventEmitter<any> = new EventEmitter()
@Output()
onShowAbortModal: EventEmitter<InfoModal> = new EventEmitter<InfoModal>()
@Output() onSasLogsModalOpen: EventEmitter<boolean> = new EventEmitter()
@Output() onRequestsModalOpen: EventEmitter<boolean> = new EventEmitter()
@Output() onDemoLimitModalShow: EventEmitter<string> =
new EventEmitter<string>()
@@ -78,8 +78,8 @@ export class EventService {
this.onShowAbortModal.emit(infoModalObject)
}
public openSasLogsModal() {
this.onSasLogsModalOpen.emit(true)
public openRequestsModal() {
this.onRequestsModalOpen.emit(true)
}
public catchResponseError(sasService: string | null, err: any) {
@@ -128,4 +128,15 @@ export class EventService {
public startupDataLoaded() {
this.onStartupDataLoaded.emit()
}
public dispatchEvent(eventName: string) {
let event
if (typeof Event === 'function') {
event = new Event(eventName)
} else {
event = document.createEvent('Event')
event.initEvent(eventName, true, true)
}
window.dispatchEvent(event)
}
}
+50 -36
View File
@@ -4,6 +4,7 @@ import { LicenseKeyData } from '../models/LicenseKeyData'
import { SasService } from './sas.service'
import moment from 'moment'
import * as base64Converter from 'base64-arraybuffer'
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'
@@ -59,6 +60,7 @@ export class LicenceService {
public isAppActivated = new BehaviorSubject<boolean | null>(null)
public isAppOverCapacity = new BehaviorSubject<boolean>(false)
public currentUserCanRegister = new BehaviorSubject<boolean>(false)
public appLocked = new BehaviorSubject<boolean>(false)
public licenseExpiresInDays = new BehaviorSubject<number | null>(null)
public isAppFreeTier = new BehaviorSubject<boolean>(false)
public licenceProblem = new BehaviorSubject<string | null>(null) //contains the link for particular licence error if any
@@ -75,9 +77,9 @@ export class LicenceService {
) {
this.sasService.incorrectSiteIdEmitter.subscribe((missmatchId: string) => {
if (this.isAppActivated.value !== null && !this.isAppFreeTier.value) {
const url = `/licensing/key?error=missmatch&missmatchId=${missmatchId}`
const url = `/licensing/key?error=missmatch&missmatchId=${missmatchId}&force=true`
this.licenceProblem.next(url)
this.deactivateApp()
this.deactivateApp(url)
}
})
}
@@ -113,10 +115,7 @@ export class LicenceService {
console.error(err)
this.isAppActivated.next(false)
this.licenceProblem.next(
`/licensing/key?error=invalid&details=${btoa(err)}`
)
this.navigateWithinLicensing(
this.router.navigateByUrl(
`/licensing/key?error=invalid&details=${btoa(err)}`
)
return
@@ -181,16 +180,19 @@ export class LicenceService {
const errString = `Error decrypting license key. ${err}`
console.error(errString)
// this.isAppActivated.next(false)
const url = `/licensing/key?error=invalid&details=${btoa(errString)}`
const url = `/licensing/key?error=invalid&details=${btoa(
errString
)}&force=true`
this.licenceProblem.next(url)
setTimeout(() => {
this.applicationActivation(
this.freeTierLicenceData,
variables,
startup_site_id
startup_site_id,
true
)
this.navigateWithinLicensing(url)
this.router.navigateByUrl(url)
})
}
)
@@ -208,12 +210,20 @@ export class LicenceService {
private applicationActivation(
licenseData: LicenseKeyData,
variables?: Globvar,
startup_site_id?: string
startup_site_id?: string,
skipRouting?: boolean
) {
if (!skipRouting) skipRouting = false
this.setSiteId(licenseData)
if (!licenseData.demo && variables && startup_site_id) {
this.handleSiteIdMissmatch(licenseData, variables, startup_site_id)
this.handleSiteIdMissmatch(
licenseData,
variables,
startup_site_id,
skipRouting
)
}
let hotLicenceKey =
@@ -241,7 +251,7 @@ export class LicenceService {
console.error('License key has expired')
if (variables && startup_site_id) {
this.handleExpiry(expiry_date, variables, startup_site_id)
this.handleExpiry(expiry_date, variables, startup_site_id, skipRouting)
}
return
@@ -250,7 +260,7 @@ export class LicenceService {
this.isAppFreeTier.next(!!licenseData.demo)
if (variables) {
this.handleUserRegistration(licenseData, variables)
this.handleUserRegistration(licenseData, variables, skipRouting)
}
this.decodeLicenceFeatures(licenseData)
@@ -359,7 +369,7 @@ export class LicenceService {
)
.then(
(value: any) => {
let keyDataString = new TextDecoder().decode(value)
let keyDataString = new encoding.TextDecoder().decode(value)
try {
resolve(JSON.parse(keyDataString))
@@ -386,20 +396,13 @@ export class LicenceService {
return this.licenseKeyData
}
/**
* Licence problems are reported by the banner on app pages, which links to
* the licensing page - startup never moves the user there. Once the user is
* on a licensing page, the error belongs on the page and is shown as before.
*/
private navigateWithinLicensing(url: string) {
if (this.router.url.includes('licensing')) this.router.navigateByUrl(url)
}
public deactivateApp() {
public deactivateApp(redirectTo?: string) {
// this.isAppActivated.next(false)
this._licenceState = freeTierConfig
this.applicationActivation(this.freeTierLicenceData)
if (redirectTo) this.router.navigateByUrl(redirectTo)
}
/**
@@ -470,7 +473,8 @@ export class LicenceService {
private handleSiteIdMissmatch(
licenseData: LicenseKeyData,
variables: Globvar,
startup_site_id: string
startup_site_id: string,
skipRouting: boolean
) {
let noLegacySiteId: boolean = false
let noMultipleSiteId: boolean = false
@@ -491,7 +495,7 @@ export class LicenceService {
// this.isAppActivated.next(false)
setTimeout(() => {
const url = '/licensing/key?error=missmatch'
const url = '/licensing/key?error=missmatch&force=true'
this.licenceProblem.next(url)
this._licenceState = freeTierConfig
@@ -499,9 +503,11 @@ export class LicenceService {
this.applicationActivation(
this.freeTierLicenceData,
variables,
startup_site_id
startup_site_id,
true
)
this.navigateWithinLicensing(url)
if (!skipRouting) this.router.navigateByUrl(url)
})
return
@@ -527,10 +533,11 @@ export class LicenceService {
private handleExpiry(
expiry_date: moment.Moment,
variables: Globvar,
startup_site_id: string
startup_site_id: string,
skipRouting: boolean
) {
setTimeout(() => {
const url = `/licensing/key?error=expired&details=${btoa(
const url = `/licensing/key?force=true&error=expired&details=${btoa(
'Expiry date: ' + expiry_date.format('DD/MM/YYYY')
)}`
this.licenceProblem.next(url)
@@ -538,9 +545,13 @@ export class LicenceService {
this.applicationActivation(
this.freeTierLicenceData,
variables,
startup_site_id
startup_site_id,
true
)
this.navigateWithinLicensing(url)
if (!skipRouting) {
this.router.navigateByUrl(url)
}
})
}
@@ -558,7 +569,8 @@ export class LicenceService {
private handleUserRegistration(
licenseData: LicenseKeyData,
variables: Globvar
variables: Globvar,
skipRouting: boolean
) {
if (this.userCountLimitation) {
//if limit disabled we don't track the count
@@ -576,21 +588,23 @@ export class LicenceService {
this.isAppActivated.next(true)
return
} else {
// if limit is disabled the user can always register
// if limit is disabled we always go into register directly
if (
variables.REGISTERCOUNT < licenseData.users_allowed ||
!this.userCountLimitation
) {
console.log('User can register')
this.currentUserCanRegister.next(true)
// this.appLocked.next(true)
this.isAppActivated.next(false)
this.navigateWithinLicensing('/licensing/register')
if (!skipRouting) this.router.navigateByUrl('/licensing/register')
//After register, activate app
return
} else {
this.currentUserCanRegister.next(false)
this.isAppActivated.next(false)
this.navigateWithinLicensing('/licensing/limit')
// this.appLocked.next(true)
if (!skipRouting) this.router.navigateByUrl('/licensing/limit')
}
}
}
@@ -116,53 +116,6 @@ describe('SasStoreService', () => {
expect(seen.length).toBe(0)
})
})
describe('saveQuery', () => {
const capture = (adapterResponse: any = { result: [] }) => {
const calls: any[][] = []
const store = new SasStoreService(
{
request: (...args: any[]) => {
calls.push(args)
return Promise.resolve({ adapterResponse })
}
} as any,
{} as any,
{} as any
)
return { store, calls }
}
it('posts the filter to public/validatefilter', async () => {
const { store, calls } = capture()
await store.saveQuery('DC.MY_TABLE', [
{ VARIABLE_NM: 'A', OPERATOR_NM: '=', RAW_VALUE: "'x'" } as any
])
expect(calls[0][0]).toBe('public/validatefilter')
expect(calls[0][1].iwant[0].filter_table).toBe('DC.MY_TABLE')
expect(calls[0][1].filterquery.length).toBe(1)
})
it('passes wrapper options through, so a rejected filter can stay quiet', async () => {
// The VA deferred load attempts a filter built from a va_parmap entry the
// table may not have, and must not cover a page that loads fine with an
// abort modal (dc/dc#404).
const { store, calls } = capture()
await store.saveQuery('DC.MY_TABLE', [], {
suppressErrorAbortModal: true
})
expect(calls[0][3]).toEqual({ suppressErrorAbortModal: true })
})
it('sends no wrapper options when the caller gives none', async () => {
const { store, calls } = capture()
await store.saveQuery('DC.MY_TABLE', [])
expect(calls[0][3]).toBeUndefined()
})
})
})
/**
+2 -20
View File
@@ -8,7 +8,6 @@ import { HelperService } from './helper.service'
import { QueryClause } from '../models/TableData'
import { globals } from '../_globals'
import { FilterClause, FilterGroup, FilterQuery } from '../models/FilterQuery'
import { RequestWrapperOptions } from '../models/request-wrapper/RequestWrapperOptions'
import {
$DataFormats,
EditorsGetDataSASResponse,
@@ -396,30 +395,13 @@ export class SasStoreService {
).adapterResponse
}
/**
* Persists a filter and returns its FILTER_RK.
*
* `wrapperOptions` reaches the request wrapper, so a caller that can live with
* a rejected filter (the VA deferred load, whose `va_parmap` entry may name a
* column the table does not have) can suppress the abort modal that would
* otherwise cover a page which is about to load perfectly well.
*/
public async saveQuery(
libds: string,
filterQuery: QueryClause[],
wrapperOptions?: RequestWrapperOptions
) {
public async saveQuery(libds: string, filterQuery: QueryClause[]) {
let tables = {
iwant: [{ filter_table: libds }],
filterquery: filterQuery
}
const res = await this.sasService.request(
'public/validatefilter',
tables,
undefined,
wrapperOptions
)
const res = await this.sasService.request('public/validatefilter', tables)
this.filter.next(res)
+2 -6
View File
@@ -286,10 +286,7 @@ export class SasService {
adapterResponse: {
error: abortMsg
},
log: sasRequest?.logFile,
/* the modal above already told the user: a caller that also
reports the failure would stack a second one on top */
errorShown: !wrapperOptions?.suppressSuccessAbortModal
log: sasRequest?.logFile
})
}
@@ -332,8 +329,7 @@ export class SasService {
adapterResponse: {
error: errorMessage
},
log: sasRequest?.logFile,
errorShown: !wrapperOptions?.suppressErrorAbortModal
log: sasRequest?.logFile
})
}
@@ -1,5 +1,4 @@
import { VaFilterService } from './va-filter.service'
import { VaParmapTarget } from '../shared/utils/parse-va-parmap'
import { VaMessage } from './va-messaging.service'
import { HelperService } from './helper.service'
@@ -15,7 +14,6 @@ function msg(parameters: any[]): VaMessage {
describe('VaFilterService', () => {
let service: VaFilterService
let helperService: HelperService
// DC column metadata used by the metadata path. cellValidation[0] is the
// delete column and must be skipped by buildLabelToVarMap.
@@ -44,8 +42,7 @@ describe('VaFilterService', () => {
]
beforeEach(() => {
helperService = new HelperService({} as any)
service = new VaFilterService(helperService)
service = new VaFilterService(new HelperService({} as any))
})
const labelToVar = () =>
@@ -131,41 +128,6 @@ describe('VaFilterService', () => {
expect(build([{ label: 'SOME_DATE', value: '2025/03/31' }])).toHaveSize(1)
})
it('parses word dates, in either order and with an optional comma', () => {
const expected = "'01JAN2026'd"
// The reported shape: a date parameter displayed as WORDDATE.
expect(
build([{ label: 'SOME_DATE', value: 'January 1, 2026' }])[0].RAW_VALUE
).toBe(expected)
expect(
build([{ label: 'SOME_DATE', value: 'Jan 1, 2026' }])[0].RAW_VALUE
).toBe(expected)
expect(
build([{ label: 'SOME_DATE', value: 'Jan. 1, 2026' }])[0].RAW_VALUE
).toBe(expected)
expect(
build([{ label: 'SOME_DATE', value: '1 January 2026' }])[0].RAW_VALUE
).toBe(expected)
expect(
build([{ label: 'SOME_DATE', value: '1-Jan-2026' }])[0].RAW_VALUE
).toBe(expected)
})
it('matches a spelled-out month case-insensitively, and keeps DATE9 working', () => {
expect(
build([{ label: 'SOME_DATE', value: 'DECEMBER 31, 2025' }])[0].RAW_VALUE
).toBe("'31DEC2025'd")
expect(
build([{ label: 'SOME_DATE', value: '01AUG1962' }])[0].RAW_VALUE
).toBe("'01AUG1962'd")
})
it('still drops a month-name value that has no day', () => {
// A month and year alone is not a date - accepted shapes always carry a
// day, so this must not silently become the first of the month.
expect(build([{ label: 'SOME_DATE', value: 'January 2026' }])).toEqual([])
})
it('resolves ambiguous slash dates day-first (never US), disambiguating by value', () => {
const iso = build([{ label: 'SOME_DATE', value: '2025-03-31' }])[0]
.RAW_VALUE
@@ -237,694 +199,7 @@ describe('VaFilterService', () => {
})
})
describe('buildClauses with an explicit va_parmap', () => {
const build = (parameters: any[], paramMap: Map<string, VaParmapTarget>) =>
service.buildClauses(msg(parameters), labelToVar(), cols, paramMap)
it('overrides the same-name match, including the value formatting', () => {
// The VA label IS a DC column here (SOME_CHAR), but the mapping points at
// a different one - the numeric column - so the value is formatted numeric.
const map = new Map([['some_char', { column: 'SOME_NUM' }]])
expect(build([{ label: 'SOME_CHAR', value: '10' }], map)).toEqual([
{
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: 'SOME_NUM',
OPERATOR_NM: '=',
RAW_VALUE: '10'
}
])
})
it('lets two differently-named params filter the same column', () => {
// The multi-tab case: one DC instance per report tab, each with its own
// VA parameter, both mapping to the same DC column.
const map = new Map([
['region_tab1', { column: 'SOME_CHAR' }],
['region_tab2', { column: 'SOME_CHAR' }]
])
const clauses = build(
[
{ label: 'REGION_TAB1', value: 'north' },
{ label: 'REGION_TAB2', value: 'south' }
],
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_CHAR', "'north'"],
['SOME_CHAR', "'south'"]
])
})
it('matches the parameter label only, not its internal name', () => {
// The internal name is the report's own bookkeeping. Matching it as well
// meant one parameter answered to two keys, so an entry written against the
// name and one against the label silently fought over it.
const map = new Map([['pr273', { column: 'SOME_NUM' }]])
expect(
build([{ name: 'pr273', label: 'Tab_1_Value', value: '7' }], map)
).toEqual([])
// The label still matches, case-insensitively.
expect(
build(
[{ name: 'pr273', label: 'Tab_1_Value', value: 'x' }],
new Map([['tab_1_value', { column: 'SOME_CHAR' }]])
)[0].VARIABLE_NM
).toBe('SOME_CHAR')
})
it('uses the column case from the loaded metadata', () => {
const map = new Map([['region_tab1', { column: 'some_char' }]])
expect(
build([{ label: 'REGION_TAB1', value: 'x' }], map)[0].VARIABLE_NM
).toBe('SOME_CHAR')
})
it('leaves unmapped params on the same-name path', () => {
const map = new Map([['region_tab1', { column: 'SOME_NUM' }]])
const clauses = build(
[
{ label: 'REGION_TAB1', value: '10' },
{ label: 'SOME_CHAR', value: 'x' }
],
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_NUM', '10'],
['SOME_CHAR', "'x'"]
])
})
it('skips a mapping whose column is not in this table (no fallback)', () => {
// Falling back to the same-name match would silently apply the filter the
// mapping exists to override, so the param is dropped instead.
const map = new Map([['some_char', { column: 'NOT_IN_THIS_TABLE' }]])
expect(build([{ label: 'SOME_CHAR', value: 'x' }], map)).toEqual([])
})
it('ANDs two parameters that map to the same column - it does not union them', () => {
// What a report gets when one column's two values arrive in two
// parameters: every clause is AND-ed, so the result is the INTERSECTION.
// Two different dates therefore match no rows at all rather than both
// dates' rows - a single parameter carrying both values is the shape that
// ORs, via `IN (...)`.
const map = new Map([
['param_a', { column: 'SOME_DATE' }],
['param_b', { column: 'SOME_DATE' }]
])
const one = build([{ label: 'PARAM_A', value: '01JUL1960' }], map)[0]
const other = build([{ label: 'PARAM_B', value: '01JAN1961' }], map)[0]
const clauses = build(
[
{ label: 'PARAM_A', value: '01JUL1960' },
{ label: 'PARAM_B', value: '01JAN1961' }
],
map
)
expect(one.RAW_VALUE).not.toBe(other.RAW_VALUE)
expect(clauses).toHaveSize(2)
expect(clauses.map((c) => c.RAW_VALUE)).toEqual([
one.RAW_VALUE,
other.RAW_VALUE
])
for (const clause of clauses) {
expect(clause.VARIABLE_NM).toBe('SOME_DATE')
expect(clause.OPERATOR_NM).toBe('=')
expect(clause.GROUP_LOGIC).toBe('AND')
expect(clause.SUBGROUP_LOGIC).toBe('AND')
expect(clause.SUBGROUP_ID).toBe(0)
}
})
it('applies a transform to the clause it builds', () => {
// ADD(QUARTER_START,-1) is the reported requirement: the selected row plus
// the row for the previous quarter, as one IN clause so the AND rule is
// untouched. 2026-10-01 = 24380, 2026-07-01 = 24288 in SAS days.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
const [clause] = build([{ label: 'SELECTED', value: '01OCT2026' }], map)
expect(clause.VARIABLE_NM).toBe('SOME_DATE')
expect(clause.OPERATOR_NM).toBe('IN')
expect(clause.RAW_VALUE).toBe('(24380,24288)')
})
it('applies a RANGE as BETWEEN', () => {
// RANGE(QUARTER,-1) for a mid-quarter value: 2026-04-01..2026-06-30
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'RANGE' as const,
period: 'QUARTER' as const,
offset: -1
}
}
]
])
const [clause] = build([{ label: 'SELECTED', value: '1962-08-15' }], map)
expect(clause.OPERATOR_NM).toBe('BETWEEN')
expect(clause.RAW_VALUE).toContain(' AND ')
expect(clause.RAW_VALUE.split(' AND ')).toHaveSize(2)
})
it('keeps equality when the value already sits on the anchor', () => {
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: 0
}
}
]
])
const [clause] = build([{ label: 'SELECTED', value: '01OCT2026' }], map)
expect(clause.OPERATOR_NM).toBe('=')
expect(clause.RAW_VALUE).toBe('24380')
})
it('drops a transform on a column it cannot apply to', () => {
// A character column has no dates to shift; the parameter is skipped rather
// than filtered on the raw value the author did not see.
const map = new Map([
[
'selected',
{
column: 'SOME_CHAR',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
expect(build([{ label: 'SELECTED', value: 'x' }], map)).toEqual([])
})
it('adds nothing when the value is unparseable as a date', () => {
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
expect(build([{ label: 'SELECTED', value: 'not a date' }], map)).toEqual(
[]
)
})
it('round-trips a bare SAS day number unchanged', () => {
// VA may send the SAS internal value rather than a formatted string. The
// Date a transform works from is local, so the number must come back out of
// the clause identical - a UTC-built Date shifts a day west of UTC.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
const [clause] = build([{ label: 'SELECTED', value: 24380 }], map)
expect(clause.RAW_VALUE).toBe('(24380,24288)')
})
it('reports a range mapping fed more than one value', () => {
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'RANGE' as const,
period: 'QUARTER' as const,
offset: -1
}
}
]
])
const problems = service.unsupportedTransforms(
msg([{ label: 'SELECTED', value: ['01APR2026', '01JUL2026'] }]),
map,
[{ NAME: 'SOME_DATE', DDTYPE: 'DATE', FMTNAME: 'DATE9' }]
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('is a range')
expect(problems[0]).toContain('2 values')
})
it('refuses a range fed more than one value, rather than picking one', () => {
// Two values, one of which does not parse: building a BETWEEN from the
// survivor would silently filter one period when VA selected two.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'RANGE' as const,
period: 'QUARTER' as const,
offset: -1
}
}
]
])
expect(
build([{ label: 'SELECTED', value: ['01APR2026', 'not a date'] }], map)
).toEqual([])
})
it('never lets a non-finite date reach the clause', () => {
// convertJsDateToSasDate returning NaN would otherwise put a literal NaN in
// the WHERE clause.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
spyOn(helperService, 'convertJsDateToSasDate').and.returnValue(NaN as any)
expect(build([{ label: 'SELECTED', value: '01OCT2026' }], map)).toEqual(
[]
)
})
it('reports a partly unparseable value set instead of covering the rest', () => {
// One good value and one that is not a date: the clause still filters on
// the good one, so the author has to be told the other was dropped.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
const problems = service.unsupportedTransforms(
msg([{ label: 'SELECTED', value: ['01OCT2026', 'not a date'] }]),
map,
cols
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('1 of its 2 values was not a date')
})
it('reports a range holding a value that does not parse', () => {
// Counted on what VA sent, not on what parsed: otherwise the second value
// vanishes and the range looks like a clean single-value case.
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'RANGE' as const,
period: 'QUARTER' as const,
offset: -1
}
}
]
])
const problems = service.unsupportedTransforms(
msg([{ label: 'SELECTED', value: ['01APR2026', 'not a date'] }]),
map,
cols
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('is a range')
expect(problems[0]).toContain('2 values')
})
it('applies a transform on the deferred initial load too', () => {
const map = new Map([
[
'selected',
{
column: 'SOME_DATE',
transform: {
op: 'ADD' as const,
anchor: 'QUARTER_START' as const,
offset: -1
}
}
]
])
const [clause] = service.buildInitialClauses(
msg([{ name: 'pr1', label: 'SELECTED', value: '01OCT2026' }]),
map
)
expect(clause.VARIABLE_NM).toBe('SOME_DATE')
expect(clause.RAW_VALUE).toBe('(24380,24288)')
})
it('behaves exactly as before with an empty map', () => {
expect(
service.buildClauses(
msg([{ label: 'SOME_CHAR', value: 'x' }]),
labelToVar(),
cols
)
).toEqual(build([{ label: 'SOME_CHAR', value: 'x' }], new Map()))
})
})
describe('unresolvedMappings (what the report author is warned about)', () => {
const report = (
parameters: any[],
map: Map<string, VaParmapTarget>,
libds?: string
) => service.unresolvedMappings(msg(parameters), map, cols, libds)
it('reports a mapping whose column is not in the loaded table', () => {
expect(
report(
[{ label: 'REGION_TAB1', value: 'x' }],
new Map([['region_tab1', { column: 'NOT_A_COLUMN' }]]),
'DC_JSLIB.MPE_X_TEST'
)
).toEqual([
'VA parameter "REGION_TAB1" is mapped to NOT_A_COLUMN, which is not a column of DC_JSLIB.MPE_X_TEST - the filter was ignored.'
])
})
it('says "this table" when the table name is not supplied', () => {
expect(
report(
[{ label: 'REGION_TAB1', value: 'x' }],
new Map([['region_tab1', { column: 'NOPE' }]])
)[0]
).toContain('is not a column of this table')
})
it('reports nothing when the mapping resolves, or the param is unmapped', () => {
expect(
report(
[{ label: 'REGION_TAB1', value: 'x' }],
new Map([['region_tab1', { column: 'some_char' }]])
)
).toEqual([])
expect(report([{ label: 'SOME_CHAR', value: 'x' }], new Map())).toEqual(
[]
)
// A mapping whose parameter this message does not carry is not an error.
expect(
report([], new Map([['region_tab1', { column: 'NOPE' }]]))
).toEqual([])
})
it('reports each mapping once, not once per message parameter', () => {
const problems = report(
[
{ label: 'REGION_TAB1', value: '1' },
{ name: 'pr99', label: 'REGION_TAB1', value: '2' }
],
new Map([['region_tab1', { column: 'NOPE' }]])
)
expect(problems).toHaveSize(1)
})
it('treats an unloaded column list as "not known yet", not as a bad mapping', () => {
// While the grid is still loading there is no metadata to judge against, so
// reporting would flash the banner on every load.
expect(
service.unresolvedMappings(
msg([{ label: 'REGION_TAB1', value: 'x' }]),
new Map([['region_tab1', { column: 'NOPE' }]]),
[],
'DC_JSLIB.MPE_X_TEST'
)
).toEqual([])
expect(
service.unresolvedMappings(
msg([{ label: 'REGION_TAB1', value: 'x' }]),
new Map([['region_tab1', { column: 'NOPE' }]]),
undefined as any,
'DC_JSLIB.MPE_X_TEST'
)
).toEqual([])
})
})
describe('sameColumnMappings (two parameters, one column)', () => {
it('reports an AND-ed pair, naming both parameters and the column', () => {
const map = new Map([
['dropdown_tab1', { column: 'SOME_DROPDOWN' }],
['dropdown_tab2', { column: 'SOME_DROPDOWN' }]
])
const problems = service.sameColumnMappings(
msg([
{ label: 'DROPDOWN_TAB1', value: 'Option 1' },
{ label: 'DROPDOWN_TAB2', value: 'Option 2' }
]),
map
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('"DROPDOWN_TAB1" and "DROPDOWN_TAB2"')
expect(problems[0]).toContain('all filter SOME_DROPDOWN')
expect(problems[0]).toContain('combined with AND')
})
it('says nothing for one parameter per column', () => {
const map = new Map([
['dropdown_tab1', { column: 'SOME_DROPDOWN' }],
['num_tab1', { column: 'SOME_NUM' }]
])
expect(
service.sameColumnMappings(
msg([
{ label: 'DROPDOWN_TAB1', value: 'Option 1' },
{ label: 'NUM_TAB1', value: '1' }
]),
map
)
).toEqual([])
expect(
service.sameColumnMappings(
msg([{ label: 'DROPDOWN_TAB1', value: 'Option 1' }]),
map
)
).toEqual([])
expect(service.sameColumnMappings(msg([]), map)).toEqual([])
})
it('says nothing when the AND-ed parameters carry the same value', () => {
// col = 'x' AND col = 'x' filters exactly as one clause does, so a banner
// claiming "different values will show no rows" would be wrong.
const map = new Map([
['dropdown_tab1', { column: 'SOME_CHAR' }],
['dropdown_tab2', { column: 'SOME_CHAR' }]
])
expect(
service.sameColumnMappings(
msg([
{ label: 'DROPDOWN_TAB1', value: 'x' },
{ label: 'DROPDOWN_TAB2', value: 'x' }
]),
map,
cols
)
).toEqual([])
// One differing value and the intersection can be empty: reported.
const problems = service.sameColumnMappings(
msg([
{ label: 'DROPDOWN_TAB1', value: 'x' },
{ label: 'DROPDOWN_TAB2', value: 'y' }
]),
map,
cols
)
expect(problems).toHaveSize(1)
expect(problems[0]).toContain('"DROPDOWN_TAB1" and "DROPDOWN_TAB2"')
})
it('leaves a column the table does not have to unresolvedMappings', () => {
// One cause, one line: the column is missing, so the AND story is moot.
const map = new Map([
['a', { column: 'NOPE' }],
['b', { column: 'NOPE' }]
])
const cols = [
{ LIBREF: 'DC_JSLIB', TABLE: 'MPE_X_TEST', VARIABLE: 'SOME_CHAR' }
]
expect(
service.sameColumnMappings(
msg([
{ label: 'A', value: '1' },
{ label: 'B', value: '2' }
]),
map,
cols
)
).toEqual([])
// Without metadata the pair is still reported: the AND semantics hold
// whatever the table turns out to be.
expect(
service.sameColumnMappings(
msg([
{ label: 'A', value: '1' },
{ label: 'B', value: '2' }
]),
map
)
).toHaveSize(1)
})
it('reports one line per contended column', () => {
const map = new Map([
['a', { column: 'SOME_DROPDOWN' }],
['b', { column: 'SOME_DROPDOWN' }],
['c', { column: 'SOME_NUM' }],
['d', { column: 'SOME_NUM' }]
])
const problems = service.sameColumnMappings(
msg([
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
{ label: 'C', value: '3' },
{ label: 'D', value: '4' }
]),
map
)
expect(problems).toHaveSize(2)
})
})
describe('unmatchedMappings (parameter-side typo)', () => {
const seen = (...keys: string[]) => new Set(keys)
it('reports a key that no message has named', () => {
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
seen('dropdown_tab_1', 'pr1'),
true
)
).toEqual([
'This object\'s va_parmap names VA parameter "dropdown_tab1", which VA is not sending - check the spelling.'
])
})
it('says nothing until a parameter has actually been seen', () => {
// Without evidence that this object receives parameters at all, an absent
// key proves nothing - VA sends only the parameters its query uses.
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
seen()
)
).toEqual([])
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
undefined
)
).toEqual([])
})
it('says nothing when every key has been seen', () => {
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
seen('dropdown_tab1'),
true
)
).toEqual([])
expect(
service.unmatchedMappings(new Map(), seen('anything'), true)
).toEqual([])
})
it('says nothing while the parameter set is still growing', () => {
// A message that introduces a new parameter name means this object's set
// has not settled, so an absent key may arrive in the next message.
expect(
service.unmatchedMappings(
new Map([['dropdown_tab1', { column: 'SOME_CHAR' }]]),
seen('pr1', 'pr2'),
false
)
).toEqual([])
})
})
describe('buildInitialClauses (no metadata, infer kind from value)', () => {
it('uses an explicit mapping instead of the label', () => {
// The deferred first load has no column metadata, so a mapped column is
// used as given and the SAS kind still comes from the value shape.
const map = new Map([
['region_tab1', { column: 'REGION' }],
['amt_tab1', { column: 'AMOUNT' }]
])
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'REGION_TAB1', dataType: 'string', value: 'n' },
{ name: 'pr2', label: 'AMT_TAB1', dataType: 'number', value: '10' }
]),
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['REGION', "'n'"],
['AMOUNT', '10']
])
})
it('falls back to the label for params the map does not name', () => {
const map = new Map([['region_tab2', { column: 'REGION' }]])
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'REGION_TAB1', value: 'n' },
{ name: 'pr2', label: 'REGION_TAB2', value: 's' }
]),
map
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['REGION_TAB1', "'n'"],
['REGION', "'s'"]
])
})
it('uses the VA label as the column and infers kinds by value shape', () => {
const clauses = service.buildInitialClauses(
msg([
@@ -958,58 +233,6 @@ describe('VaFilterService', () => {
['SOME_DTTM', "'01JAN1960:00:06:05'dt"]
])
})
it('infers a month-name date, which is what a WORDDATE parameter sends', () => {
// The reported shape. Before the shape was recognised this became the
// quoted literal 'December 01, 2021', which a numeric date column cannot
// be compared with - the service aborted with a type error.
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'SOME_DATE', value: 'December 01, 2021' },
{ name: 'pr2', label: 'SOME_DATE2', value: 'Dec 1, 2021' },
{ name: 'pr3', label: 'SOME_DATE3', value: '1 December 2021' },
{ name: 'pr4', label: 'SOME_DATE4', value: '1-Dec-2021' }
])
)
expect(clauses.map((c) => [c.VARIABLE_NM, c.RAW_VALUE])).toEqual([
['SOME_DATE', "'01DEC2021'd"],
['SOME_DATE2', "'01DEC2021'd"],
['SOME_DATE3', "'01DEC2021'd"],
['SOME_DATE4', "'01DEC2021'd"]
])
})
it('infers a month-name date whatever the VA dataType says', () => {
// The VA dataType is a fallback, not a fact: a date parameter can arrive
// as 'number' (where the value was previously dropped, leaving the first
// load silently unfiltered) or as 'string' (where it was quoted).
for (const dataType of ['number', 'string', undefined]) {
const clauses = service.buildInitialClauses(
msg([
{
name: 'pr1',
label: 'SOME_DATE',
dataType,
value: 'December 01, 2021'
}
])
)
expect(clauses.map((c) => c.RAW_VALUE)).toEqual(["'01DEC2021'd"])
}
})
it('infers a month-name datetime', () => {
const clauses = service.buildInitialClauses(
msg([
{ name: 'pr1', label: 'SOME_DTTM', value: 'Dec 1, 2021 10:30' },
{ name: 'pr2', label: 'SOME_DTTM2', value: '01DEC2021:10:30' }
])
)
expect(clauses.map((c) => c.RAW_VALUE)).toEqual([
"'01DEC2021:10:30:00'dt",
"'01DEC2021:10:30:00'dt"
])
})
})
describe('signature', () => {
+59 -478
View File
@@ -2,12 +2,6 @@ import { Injectable } from '@angular/core'
import { HelperService } from './helper.service'
import { VaMessage } from './va-messaging.service'
import { QueryClause } from '../models/TableData'
import { VaParmapTarget } from '../shared/utils/parse-va-parmap'
import {
applyVaTransform,
isRangeTransform,
VaTransform
} from '../shared/utils/va-parmap-transform'
/** SAS data-type kind used to format a VA filter value into a DC RAW_VALUE. */
type VaColumnKind = 'char' | 'numeric' | 'time' | 'date' | 'datetime'
@@ -60,19 +54,11 @@ export class VaFilterService {
* Translates VA `parameters` into DC filter clauses. Each parameter is matched
* to a DC column by label; a single value becomes `col = value`, multiple
* values become `col IN (...)`. Values are formatted for the column's SAS kind.
*
* `paramMap` (from the URL `va_parmap` parameter, see parseVaParmap) overrides
* that same-name resolution for the parameters it names, so two DC instances
* over one table can filter the same column from differently-named VA
* parameters. A mapped column must exist in `cols` - if it does not the
* parameter is skipped rather than falling back to the same-name match, which
* is exactly the behaviour the mapping exists to override.
*/
buildClauses(
msg: VaMessage,
labelToVar: Map<string, string>,
cols: any[],
paramMap?: Map<string, VaParmapTarget>
cols: any[]
): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
@@ -81,194 +67,60 @@ export class VaFilterService {
.toString()
.trim()
.toLowerCase()
// An explicit URL mapping wins over the label->column lookup. The mapped
// column has to exist in THIS table, else the entry does not apply here
// (and the same-name fallback would be the behaviour it exists to
// override).
const mapped = this.mappedColumn(param, paramMap)
const mappedCol = mapped ? this.findCol(mapped, cols) : undefined
if (mapped && !mappedCol) continue
// The table's own case, so the clause matches what the metadata build
// produces for a same-name match.
const varName: string = mappedCol
? mappedCol.NAME
: (labelToVar.get(key) ?? '')
// A VA param whose label isn't a DC column (beyond case, which is handled)
// simply doesn't map — skip it.
const varName = labelToVar.get(key)
if (!varName) continue
const col = mappedCol ?? this.findCol(varName, cols)
const col = this.findCol(varName, cols)
const kind = this.columnKind(col)
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const built = this.buildClauseValueSet(
param,
kind,
order,
this.mappedTarget(param, paramMap)?.transform
)
if (!built) continue
clauses.push({
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: varName,
OPERATOR_NM: built.operator,
RAW_VALUE: built.rawValue
})
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
// Format/validate every value for the column's SAS type. Numeric columns
// must receive a finite number (untrusted postMessage input is
// interpolated into a SAS WHERE clause, so a non-numeric value here would
// be an injection vector); temporal -> SAS internal number; char ->
// escaped. Invalid values are dropped.
const formatted = rawValues
.filter((v) => v !== undefined && v !== null && v !== '')
.map((v) => this.formatValueByKind(v, kind, order))
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted))
}
return clauses
}
/**
* The operator and raw value for one parameter, after any transform.
*
* Values are formatted for the column's SAS kind: numeric columns must receive
* a finite number (untrusted postMessage input is interpolated into a WHERE
* clause, so anything else would be an injection vector); temporal values
* become SAS internal numbers; char values are escaped. Invalid values are
* dropped, and a transform that cannot be applied to the column's kind yields
* nothing at all - the caller reports that separately rather than filtering on
* a value the author did not choose.
*/
private buildClauseValueSet(
param: any,
kind: VaColumnKind,
order: 'YMD' | 'DMY' | 'MDY' | undefined,
transform?: VaTransform
): { operator: '=' | 'IN' | 'BETWEEN'; rawValue: string } | null {
const rawValues = (
Array.isArray(param.value) ? param.value : [param.value]
).filter((v: any) => v !== undefined && v !== null && v !== '')
if (rawValues.length === 0) return null
if (!transform) {
const formatted = rawValues
.map((v: any) => this.formatValueByKind(v, kind, order))
.filter((v: string | null): v is string => v !== null)
if (formatted.length === 0) return null
return formatted.length === 1
? { operator: '=', rawValue: formatted[0] }
: { operator: 'IN', rawValue: `(${formatted.join(',')})` }
}
// Transforms resolve to dates, so only temporal columns can take one.
if (kind !== 'date' && kind !== 'datetime') return null
const unit = kind === 'datetime' ? 'seconds' : 'days'
const values = new Set<string>()
for (const raw of rawValues) {
const resolved = this.valueAsDate(raw, kind, order)
if (!resolved) continue
for (const date of applyVaTransform(
resolved,
transform,
kind === 'datetime'
)) {
// A non-finite result (an Invalid Date reaching the helper) must never be
// interpolated into the clause: `NaN` is not a SAS value. Dropped here,
// and reported by unsupportedTransforms so the parameter is not silently
// filtered on nothing.
const sasDate = this.helperService.convertJsDateToSasDate(date, unit)
if (!Number.isFinite(Number(sasDate))) continue
values.add(`${sasDate}`)
}
}
const formatted = Array.from(values)
if (formatted.length === 0) return null
if (isRangeTransform(transform)) {
// One BETWEEN carries one period, so more than one value has no clause -
// dropping it here (rather than building a BETWEEN from whichever value
// parsed) is what unsupportedTransforms' count warning then describes.
if (rawValues.length > 1) return null
if (formatted.length !== 2) return null
return { operator: 'BETWEEN', rawValue: formatted.join(' AND ') }
}
return formatted.length === 1
? { operator: '=', rawValue: formatted[0] }
: { operator: 'IN', rawValue: `(${formatted.join(',')})` }
}
/**
* A VA value as a local Date, for a transform to work from. Formatted strings
* are parsed by shape; a bare number is already the SAS internal value, so it
* is converted back from days (date) or seconds (datetime) since 1960-01-01.
*
* Built with local calendar construction, matching `parseDate` and
* `HelperService.convertJsDateToSasDate`: a UTC instant read back through local
* accessors lands on the previous day west of UTC, and the round trip to a SAS
* day number would be one out.
*/
private valueAsDate(
value: any,
kind: VaColumnKind,
order?: 'YMD' | 'DMY' | 'MDY'
): Date | null {
const s = String(value).trim()
if (s === '') return null
if (this.isBareNumber(s)) {
const internal = Number(s)
if (kind !== 'datetime') return new Date(1960, 0, 1 + internal)
// Seconds since midnight 1960-01-01, split into whole days and a remainder
// so the constructor normalises the clock time locally.
const days = Math.floor(internal / 86400)
const seconds = internal - days * 86400
return new Date(1960, 0, 1 + days, 0, 0, seconds)
}
return kind === 'datetime'
? this.parseDatetime(s, order)
: this.parseDate(s, order)
}
/**
* Pre-getdata variant used on the deferred initial load, when DC column
* metadata isn't available. Uses the VA `label` as the column name and infers
* the SAS kind from the value shape, so the clause matches what the metadata
* build later produces (no reconcile reload).
*
* `paramMap` overrides the label-as-column rule for the parameters it names,
* so the very first (already filtered) load also honours an explicit mapping.
*/
buildInitialClauses(
msg: VaMessage,
paramMap?: Map<string, VaParmapTarget>
): QueryClause[] {
buildInitialClauses(msg: VaMessage): QueryClause[] {
const clauses: QueryClause[] = []
for (const param of msg.parameters || []) {
if (!param || param.value === undefined || param.value === null) continue
const target = this.mappedTarget(param, paramMap)
// Without a mapping the DC column name is the VA `label`; `name` is VA's
// internal id (pr###). A `va_parmap` entry wins over both.
const varName = (target?.column ?? param.label ?? param.name ?? '')
.toString()
.trim()
// The DC column name is the VA `label`; `name` is VA's internal id (pr###).
const varName = (param.label ?? param.name ?? '').toString().trim()
if (!varName) continue
const rawValues = (
Array.isArray(param.value) ? param.value : [param.value]
) as any[]
const rawValues = Array.isArray(param.value) ? param.value : [param.value]
const nonEmpty = rawValues.filter(
(v) => v !== undefined && v !== null && v !== ''
)
if (nonEmpty.length === 0) continue
// A transform only ever produces dates, so it fixes the kind: inferring it
// from the value would see a formatted date either way, but with the
// transform the column is known to be temporal.
const kind = target?.transform
? ('date' as VaColumnKind)
: this.inferKindFromValue(String(nonEmpty[0]).trim(), param.dataType)
const built = this.buildClauseValueSet(
param,
kind,
undefined,
target?.transform
)
if (!built) continue
clauses.push({
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: varName,
OPERATOR_NM: built.operator,
RAW_VALUE: built.rawValue
})
const formatted = nonEmpty
.map((v) =>
this.formatValueByKind(
v,
this.inferKindFromValue(String(v).trim(), param.dataType)
)
)
.filter((v): v is string => v !== null)
if (formatted.length === 0) continue
clauses.push(this.clause(varName, formatted))
}
return clauses
}
@@ -289,254 +141,16 @@ export class VaFilterService {
)
}
/**
* Transform mappings in this message that cannot be applied, one plain-English
* line each: the column is not temporal, or the value is not a date the browser
* can parse. `buildClauseValueSet` skips those parameters, so this is what stops
* the misconfiguration reading as a filter that simply matched nothing.
*
* A missing column is left to `unresolvedMappings`, which already reports it.
*/
unsupportedTransforms(
msg: VaMessage,
paramMap: Map<string, VaParmapTarget> | undefined,
cols: any[]
): string[] {
if (!paramMap || paramMap.size === 0) return []
if (!cols || cols.length === 0) return []
const problems = new Set<string>()
for (const param of msg.parameters || []) {
const target = this.mappedTarget(param, paramMap)
if (!target?.transform) continue
const transform = target.transform
const col = this.findCol(target.column, cols)
if (!col) continue
const kind = this.columnKind(col)
const paramName = (param.label ?? param.name ?? '').toString().trim()
const spec =
target.transform.op === 'ADD'
? `ADD(${target.transform.anchor},${target.transform.offset})`
: `RANGE(${target.transform.period},${target.transform.offset})`
if (kind !== 'date' && kind !== 'datetime') {
problems.add(
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but ${target.column} is not a date or datetime column - the filter was ignored.`
)
continue
}
const order = this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT)
const sent = (
Array.isArray(param.value) ? param.value : [param.value]
).filter((v: any) => v !== undefined && v !== null && v !== '')
const usable = sent.filter((v: any) => {
const date = this.valueAsDate(v, kind, order)
if (!date) return false
// Same test the clause builder applies, so this count matches what it uses.
return applyVaTransform(date, transform, kind === 'datetime').every(
(resolved) => {
const sasDate = this.helperService.convertJsDateToSasDate(
resolved,
kind === 'datetime' ? 'seconds' : 'days'
)
return Number.isFinite(Number(sasDate))
}
)
})
if (usable.length === 0) {
problems.add(
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but its value was not a date - the filter was ignored.`
)
continue
}
// One BETWEEN can only express one period. A multi-select would have to
// become an OR of ranges, which the clause model does not carry, so say so
// rather than building a BETWEEN from whichever value happened to parse.
// Counted on what VA sent: a second value that does not parse must not make
// the single-value case look clean.
if (isRangeTransform(target.transform) && sent.length > 1) {
problems.add(
`VA parameter "${paramName}" maps to ${target.column}:${spec}, which is a range, but the parameter has ${sent.length} values - a range needs one value, so the filter was ignored.`
)
continue
}
if (usable.length < sent.length) {
problems.add(
`VA parameter "${paramName}" maps to ${target.column}:${spec}, but ${sent.length - usable.length} of its ${sent.length} values was not a date - the filter covers only the rest.`
)
}
private clause(varName: string, formatted: string[]): QueryClause {
return {
GROUP_LOGIC: 'AND',
SUBGROUP_LOGIC: 'AND',
SUBGROUP_ID: 0,
VARIABLE_NM: varName,
OPERATOR_NM: formatted.length === 1 ? '=' : 'IN',
RAW_VALUE:
formatted.length === 1 ? formatted[0] : `(${formatted.join(',')})`
}
return Array.from(problems)
}
/**
* `va_parmap` entries that this message actually uses but that name a column
* the loaded table does not have, one plain-English line each. `buildClauses`
* skips those parameters (deliberately: falling back to the same-name match
* would silently apply the filter the mapping exists to override), so this is
* what makes the misconfiguration visible to the report author - otherwise the
* grid simply shows every row, which looks like a working filter.
*/
unresolvedMappings(
msg: VaMessage,
paramMap: Map<string, VaParmapTarget> | undefined,
cols: any[],
libds?: string
): string[] {
const problems = new Set<string>()
if (!paramMap || paramMap.size === 0) return []
// No metadata yet is not the same as "the table lacks this column": an empty
// `cols` means the grid is still loading, so there is nothing to judge yet.
// Reporting here would flash a misconfiguration banner during every load.
if (!cols || cols.length === 0) return []
for (const param of msg.parameters || []) {
const mapped = this.mappedColumn(param, paramMap)
if (!mapped || this.findCol(mapped, cols)) continue
const paramName = (param.label ?? param.name ?? '').toString().trim()
problems.add(
`VA parameter "${paramName}" is mapped to ${mapped}, which is not a column of ${
libds || 'this table'
} - the filter was ignored.`
)
}
return Array.from(problems)
}
/**
* `va_parmap` keys that no VA message has named, one plain-English line each.
* A key that matches nothing is a parameter-side typo: the mapping never
* applies and the grid stays unfiltered, which looks the same as a working
* filter.
*
* `seenKeys` must be every key observed across messages so far. `paramsStable`
* says the latest message introduced no new parameter name, i.e. this object's
* parameter set has settled - until then a missing name may simply not have
* arrived, because VA sends only the parameters its query uses.
*/
unmatchedMappings(
paramMap: Map<string, VaParmapTarget> | undefined,
seenKeys: Set<string> | undefined,
paramsStable = false
): string[] {
if (!paramMap || paramMap.size === 0) return []
if (!seenKeys || seenKeys.size === 0) return []
if (!paramsStable) return []
const problems: string[] = []
for (const key of Array.from(paramMap.keys())) {
if (!seenKeys.has(key)) {
problems.push(
`This object's va_parmap names VA parameter "${key}", which VA is not sending - check the spelling.`
)
}
}
return problems
}
/**
* Parameters in this message that filter the same column with *different*
* values, one plain-English line per column. Clauses are AND-ed, so two
* parameters on one column are an intersection: with different values the grid
* comes back empty, which looks like a working filter. Two parameters carrying
* the same value intersect cleanly and filter correctly, so they are not
* reported - a banner there would call a working configuration broken.
*
* Reported rather than rewritten - folding them into one `IN (...)` would
* silently turn an AND into an OR, which is a semantics decision, not a
* diagnostic.
*/
sameColumnMappings(
msg: VaMessage,
paramMap: Map<string, VaParmapTarget> | undefined,
cols?: any[]
): string[] {
if (!paramMap || paramMap.size === 0) return []
// column -> the parameters on it, each with the clause value it produces
const byColumn = new Map<string, { name: string; value: string }[]>()
for (const param of msg.parameters || []) {
const mapped = this.mappedColumn(param, paramMap)
if (!mapped) continue
// A column the table lacks is unresolvedMappings' finding; counting it here
// would report one cause as two symptoms.
const col =
cols && cols.length > 0 ? this.findCol(mapped, cols) : undefined
if (cols && cols.length > 0 && !col) continue
// A parameter that produces no clause cannot conflict with anything.
const value = this.valueSignature(
param,
col,
this.mappedTarget(param, paramMap)?.transform
)
if (value === null) continue
const column = mapped.toUpperCase()
const params = byColumn.get(column) || []
const paramName = (param.label ?? '').toString().trim()
if (paramName && !params.some((p) => p.name === paramName)) {
params.push({ name: paramName, value })
}
byColumn.set(column, params)
}
const problems: string[] = []
for (const [column, params] of Array.from(byColumn.entries())) {
if (params.length < 2) continue
// Same value on every parameter: the AND is a no-op, not an empty grid.
const distinct = new Set(params.map((p) => p.value))
if (distinct.size === 1) continue
problems.push(
`VA parameters ${params
.map((p) => `"${p.name}"`)
.join(
' and '
)} all filter ${column}. Parameters are combined with AND, so rows must match all of them - different values will show no rows. Use one parameter carrying both values to match either.`
)
}
return problems
}
/**
* The DC column an explicit URL mapping (`va_parmap`) assigns to this
* parameter, or undefined when the parameter is not mapped. The key is matched
* case-insensitively against the parameter's `label`.
*/
private mappedTarget(
param: any,
paramMap?: Map<string, VaParmapTarget>
): VaParmapTarget | undefined {
if (!paramMap || paramMap.size === 0) return undefined
// The label only. The internal name (pr273) is an implementation detail of
// the report, and matching both made one parameter answer to two keys: a
// mapping written against the name could never be distinguished from a typo
// once the label was also mapped, and the second entry silently lost.
const key = (param.label ?? '').toString().trim().toLowerCase()
if (!key) return undefined
return paramMap.get(key)
}
/**
* The clause value a parameter would filter its column with, as a comparable
* string, or null when it produces no clause at all. Used to tell an AND of two
* identical values (harmless) from an AND of two different ones (empty grid).
*
* Delegates to the clause builder so a transform's derived dates take part in
* the comparison: two parameters adding the same quarter are the same clause.
*/
private valueSignature(
param: any,
col: any,
transform?: VaTransform
): string | null {
const built = this.buildClauseValueSet(
param,
this.columnKind(col),
this.dateOrderFromFmt(col?.FMTNAME || col?.FORMAT),
transform
)
return built ? built.rawValue : null
}
/** The mapped column name, for callers that do not need the transform. */
private mappedColumn(
param: any,
paramMap?: Map<string, VaParmapTarget>
): string | undefined {
return this.mappedTarget(param, paramMap)?.column
}
/** The loaded column spec whose NAME matches varName (case-insensitive). */
@@ -588,25 +202,20 @@ export class VaFilterService {
/**
* Infers a column's SAS kind from a VA value string (deferred load, no
* metadata).
*
* The value is offered to the same parsers the clause builder formats with, so
* any date shape that can be formatted is also recognised here. Datetime is
* tried first because it needs a time part, then date, then time; anything left
* falls back to the VA `dataType`, and finally to character.
*
* The result is a guess by construction: the deferred path has no column
* metadata, so a shape this function cannot name is formatted as character,
* and a character parameter whose value happens to look like a date ("1 May
* 2024") is formatted as a date. Either way the clause can disagree with the
* column, which costs the deferred `validatefilter` call: it is attempted with
* `suppressErrorAbortModal`, and when it fails the editor falls back to the
* unfiltered load and the post-load replay rebuilds the filter against the
* real metadata.
* metadata). VA's temporal strings are distinctive — DATE9 `ddMMMyyyy` and ISO
* `yyyy-MM-dd`/`yyyy/MM/dd`, optionally with a `[ T:]HH:MM[:SS]` time part, and
* `H:MM:SS` — so they're detected by shape; otherwise fall back to the VA
* `dataType`.
*/
private inferKindFromValue(value: string, dataType?: string): VaColumnKind {
if (this.parseDatetime(value)) return 'datetime'
if (this.parseDate(value)) return 'date'
const monName = String.raw`\d{1,2}[A-Za-z]{3}\d{2,4}`
const ymd = String.raw`\d{4}[-/.]\d{1,2}[-/.]\d{1,2}` // 4-digit year first
const dmy = String.raw`\d{1,2}[-/.]\d{1,2}[-/.]\d{4}` // 4-digit year last
const date = `${monName}|${ymd}|${dmy}`
if (new RegExp(`^(?:${date})[ T:]\\d{1,2}:\\d{2}(:\\d{2})?$`).test(value)) {
return 'datetime'
}
if (new RegExp(`^(?:${date})$`).test(value)) return 'date'
if (/^\d{1,2}:\d{2}(:\d{2})?(\.\d+)?$/.test(value)) return 'time'
if (dataType === 'number') return 'numeric'
return 'char'
@@ -712,42 +321,14 @@ export class VaFilterService {
return this.parseMonthNameDate(value) ?? this.parseNumericDate(value, order)
}
/**
* Month-name date shapes -> local Date, or null. Three families:
* - DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62);
* - word date with the month first, comma optional: "January 1, 2026",
* "Jan 1 2026", "Jan. 1, 2026";
* - word date with the day first: "1 January 2026", "1-Jan-2026".
*
* A date parameter's display format decides which of these the report author
* sees, and VA sends the formatted text, so a report using WORDDATE (or a
* long month name in any order) has to parse here rather than being dropped.
* The month name may be abbreviated or spelled out; only its first three
* letters are matched.
*/
/** DATE9 "ddMMM[yy]yy" (e.g. 01AUG1962, 01AUG62) -> local Date, or null. */
private parseMonthNameDate(value: string): Date | null {
const monthByName = (name: string): number =>
VaFilterService.SAS_MONTHS.indexOf(
name.replace(/\./g, '').slice(0, 3).toUpperCase()
)
const date9 = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
if (date9) {
const [, day, monthAbbr, year] = date9
const monthIndex = monthByName(monthAbbr)
if (monthIndex < 0) return null
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
}
const monthFirst =
/^([A-Za-z]{3,9})\.?[\s-]+(\d{1,2}),?[\s-]+(\d{2}|\d{4})$/.exec(value)
const dayFirst =
/^(\d{1,2})[\s-]+([A-Za-z]{3,9})\.?,?[\s-]+(\d{2}|\d{4})$/.exec(value)
const name = monthFirst ? monthFirst[1] : dayFirst ? dayFirst[2] : null
if (!name) return null
const day = monthFirst ? monthFirst[2] : dayFirst![1]
const year = monthFirst ? monthFirst[3] : dayFirst![3]
const monthIndex = monthByName(name)
const m = /^(\d{1,2})([A-Za-z]{3})(\d{2}|\d{4})$/.exec(value)
if (!m) return null
const [, day, monthAbbr, year] = m
const monthIndex = VaFilterService.SAS_MONTHS.indexOf(
monthAbbr.toUpperCase()
)
if (monthIndex < 0) return null
return this.buildDate(this.fullYear(year), monthIndex + 1, Number(day))
}
@@ -1,6 +1,4 @@
import { VaMessagingService, VaMessage } from './va-messaging.service'
import { VaFilterService } from './va-filter.service'
import { HelperService } from './helper.service'
// Sample message taken from the official SAS DDC docs (brush column included).
const SAMPLE = {
@@ -53,57 +51,6 @@ describe('VaMessagingService', () => {
})
})
describe('parameter keys (what the va_parmap typo diagnostic is judged on)', () => {
const PARAMS = [
{ name: 'pr273', label: 'Tab_1_Date', value: '2025-03-31' },
{ name: 'pr274', label: 'Tab 2 Region', value: 'north' }
]
const withParams = () => ({ ...SAMPLE, parameters: PARAMS })
it('records each parameter label once, across messages', () => {
service.parseMessage({ data: withParams() } as MessageEvent)
service.parseMessage({ data: withParams() } as MessageEvent)
expect(Array.from(service.seenParamKeys)).toEqual([
'tab_1_date',
'tab 2 region'
])
// The second message introduced no new label, so the set has settled.
expect(service.lastMessageParamsStable).toBeTrue()
})
it('records the label only, so a mapping written against the internal name is reported', () => {
// `pr273` is VA's own bookkeeping. A va_parmap entry written against it
// never matches a parameter, and recording the name here would make the
// typo diagnostic believe VA is sending it - leaving the author with an
// unfiltered grid and no warning at all.
service.parseMessage({ data: withParams() } as MessageEvent)
service.parseMessage({ data: withParams() } as MessageEvent)
const filter = new VaFilterService(new HelperService({} as any))
const seen = service.seenParamKeys
const stable = service.lastMessageParamsStable
expect(
filter.unmatchedMappings(
new Map([['pr273', { column: 'D_DATE' }]]),
seen,
stable
)
).toEqual([
'This object\'s va_parmap names VA parameter "pr273", which VA is not sending - check the spelling.'
])
// The label is a real key, so the same map written that way is silent.
expect(
filter.unmatchedMappings(
new Map([['tab_1_date', { column: 'D_DATE' }]]),
seen,
stable
)
).toEqual([])
})
})
describe('dataColumns', () => {
it('excludes brush columns and keeps original indices', () => {
const msg = service.parseMessage({ data: SAMPLE } as MessageEvent)!
@@ -82,26 +82,6 @@ export class VaMessagingService {
/** DC variable names that should be visible (the rest are hidden). */
visibleColumns: string[] | null = null
/**
* Lower-cased label of every parameter any VA message has carried so far, kept
* across editor reloads. Lets the editor tell a `va_parmap` key that names no
* parameter in the report (a typo, so the filter would silently never apply)
* from one that is merely absent from the current message - VA only sends the
* parameters its query uses.
*
* Labels only: that is the key a mapping is matched on, and recording the
* internal name as well would make a name-keyed entry - which never applies -
* look like a parameter VA is sending.
*/
seenParamKeys = new Set<string>()
/**
* True when every parameter name in the latest message had already been seen.
* VA sends only the parameters a report object's query uses, and sends them per
* object, so a name that is absent from the first messages may simply not have
* arrived yet. A stable parameter set is what makes absence meaningful.
*/
lastMessageParamsStable = false
/** True once the pre-bootstrap early buffer has been drained (drain-once). */
private earlyDrained = false
@@ -207,22 +187,6 @@ export class VaMessagingService {
if (typeof data.resultName !== 'string') return null
if (!Array.isArray(data.columns) || !Array.isArray(data.data)) return null
const parameters = Array.isArray(data.parameters) ? data.parameters : []
const keys: string[] = []
for (const param of parameters) {
// The label only, which is the key a `va_parmap` entry is matched on. The
// internal name (`pr273`) is the report's own bookkeeping: recording it
// here would let the typo diagnostic below believe a name-keyed entry is
// being sent when no code path can ever apply it.
const key = (param?.label ?? '').toString().trim().toLowerCase()
if (key && !keys.includes(key)) keys.push(key)
}
// Judged before the keys are recorded: a message that introduces a new name
// means the set is still growing, so a missing name proves nothing yet.
this.lastMessageParamsStable =
keys.length > 0 && keys.every((key) => this.seenParamKeys.has(key))
for (const key of keys) this.seenParamKeys.add(key)
return {
version: data.version,
resultName: data.resultName,
@@ -230,7 +194,7 @@ export class VaMessagingService {
availableRowCount: data.availableRowCount,
data: data.data,
columns: data.columns,
parameters
parameters: Array.isArray(data.parameters) ? data.parameters : []
}
}
@@ -52,9 +52,9 @@
<button
type="button"
class="btn btn-primary"
(click)="openSasLogsModal()"
(click)="openRequestsModal()"
>
Open logs modal
Open requests modal
</button>
}
@@ -115,8 +115,8 @@ export class InfoModalComponent implements OnInit {
)
}
openSasLogsModal() {
this.eventService.openSasLogsModal()
openRequestsModal() {
this.eventService.openRequestsModal()
}
reload() {
@@ -1,67 +0,0 @@
import { EventEmitter } from '@angular/core'
import { fakeAsync, tick } from '@angular/core/testing'
import { AutocompleteComponent } from './autocomplete.component'
/**
* A sidebar toggle moves the input without a window resize, so the dropdown
* has to follow the layout event itself. SidebarComponent used to reach this
* component by dispatching a synthetic window resize; that event is gone, so
* this subscription is the only thing keeping an open dropdown aligned.
*
* The component only needs ChangeDetectorRef and EventService, so it can be
* constructed directly - same approach as the sidebar spec. The work it
* schedules is on a timer, so the specs drive the fake clock rather than
* sleeping: a wall-clock wait would let a slow runner assert before the
* component ever ran.
*
* What these cover is the scheduling contract, not layout: that a toggle
* schedules one measurement, that a queued measurement is cancelled on
* destroy, and that nothing is measured after unsubscribe. Whether the
* measurement happens before or after a CSS transition is not observable here
* and is not what the component depends on.
*/
describe('AutocompleteComponent', () => {
const buildComponent = (eventService: any) =>
new AutocompleteComponent({ detectChanges: () => {} } as any, eventService)
it('repositions the dropdown when the sidebar toggles', fakeAsync(() => {
const eventService: any = { onSidebarToggle: new EventEmitter<any>() }
const component = buildComponent(eventService)
const reposition = spyOn(component, 'calculateInputPosition')
component.ngOnInit()
eventService.onSidebarToggle.emit({ open: false })
tick()
expect(reposition).toHaveBeenCalled()
}))
it('stops listening once destroyed', fakeAsync(() => {
const eventService: any = { onSidebarToggle: new EventEmitter<any>() }
const component = buildComponent(eventService)
const reposition = spyOn(component, 'calculateInputPosition')
component.ngOnInit()
component.ngOnDestroy()
eventService.onSidebarToggle.emit({ open: false })
tick()
expect(reposition).not.toHaveBeenCalled()
}))
it('cancels a reposition already queued when it is destroyed', fakeAsync(() => {
// Unsubscribing stops future notifications but not a timer already
// scheduled by one - a toggle immediately followed by destroy would still
// measure against a torn-down view.
const eventService: any = { onSidebarToggle: new EventEmitter<any>() }
const component = buildComponent(eventService)
const reposition = spyOn(component, 'calculateInputPosition')
component.ngOnInit()
eventService.onSidebarToggle.emit({ open: false })
component.ngOnDestroy()
tick()
expect(reposition).not.toHaveBeenCalled()
}))
})
@@ -5,14 +5,11 @@ import {
EventEmitter,
HostListener,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
ViewEncapsulation
} from '@angular/core'
import { Subscription } from 'rxjs'
import { EventService } from '../../services/event.service'
export type OnLoadingMoreEvent = {
loadMoreFinished: (moreExists: boolean) => void
@@ -25,7 +22,7 @@ export type OnLoadingMoreEvent = {
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class AutocompleteComponent implements OnInit, AfterViewInit, OnDestroy {
export class AutocompleteComponent implements OnInit, AfterViewInit {
@ViewChild('input') inputElement: any
@Input() inputId: string = ''
@@ -59,36 +56,9 @@ export class AutocompleteComponent implements OnInit, AfterViewInit, OnDestroy {
loadingMore: boolean = false
constructor(
private cdr: ChangeDetectorRef,
private eventService: EventService
) {}
constructor(private cdr: ChangeDetectorRef) {}
private sidebarSub?: Subscription
private repositionTimer: any
ngOnInit(): void {
// A sidebar toggle moves this input without a window resize, which is the
// one layout change the dropdown has to follow. SidebarComponent used to
// reach here by dispatching a synthetic window resize; ask the layout
// event directly instead, so nothing depends on that event being fired.
this.sidebarSub = this.eventService.onSidebarToggle.subscribe(() => {
// Deferred by one task, not by an animation: the sidebar collapse is a
// plain `margin-left` change with no transition on the element
// (styles.scss `clr-vertical-nav.closed`), but Angular applies the
// class binding only after this handler returns, so measuring now would
// read the pre-toggle layout. The timer is held so a destroy between the
// toggle and the measurement cannot run it against a torn-down view.
clearTimeout(this.repositionTimer)
this.repositionTimer = setTimeout(() => this.calculateInputPosition())
})
}
ngOnDestroy(): void {
this.sidebarSub?.unsubscribe()
clearTimeout(this.repositionTimer)
this.repositionTimer = undefined
}
ngOnInit(): void {}
ngAfterViewInit(): void {
setTimeout(() => {
@@ -14,7 +14,6 @@ import {
DcValidationRuleUpdate
} from './models/dc-validation.model'
import { DQRule, DQRuleTypes } from './models/dq-rules.model'
import { CaseTransform } from './utils/caseTransform'
import { getDqDataCols } from './utils/getDqDataCols'
import { getNotNullDefault } from './utils/getNotNullDefault'
import { mergeColsRules } from './utils/mergeColsRules'
@@ -248,39 +247,6 @@ export class DcValidator {
return isNaN(digits) ? undefined : digits
}
/**
* Returns the case transform a `CASE` rule asks for on the given column, so
* the EDIT grid can commit the value in that case instead of flagging it.
*
* Only the two rule values the CASE validation understands are mapped
* (`UPCASE`, `LOWCASE`, see dq-validation.ts) - anything else leaves the
* value alone, which keeps the transform and the validation in step.
*
* @param col column name or index
* @returns 'upper', 'lower', or undefined when the column carries no usable
* CASE rule
*/
getCaseTransform(col: string | number): CaseTransform | undefined {
const rule = this.getRule(col)
// a column that is not in the grid has no data name to match a rule on
if (!rule?.data) return undefined
const caseRule = this.dqrules.find(
(dqrule: DQRule) =>
dqrule.BASE_COL === rule.data && dqrule.RULE_TYPE === 'CASE'
)
switch (caseRule?.RULE_VALUE) {
case 'UPCASE':
return 'upper'
case 'LOWCASE':
return 'lower'
default:
return undefined
}
}
/**
* Returns the RULE_VALUEs of a HARDREGEX/SOFTREGEX rule on the given
* column, for display in the column-header info dropdown. Both are
@@ -1041,66 +1041,6 @@ describe('DC Validator', () => {
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toBeUndefined()
})
})
describe('getCaseTransform', () => {
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('maps CASE=UPCASE to upper and CASE=LOWCASE to lower', () => {
const dcValidator = buildValidator(example_dqRules)
expect(dcValidator.getCaseTransform('SOME_CHAR')).toEqual('upper')
expect(dcValidator.getCaseTransform('SOME_CHAR_LOW')).toEqual('lower')
})
it('resolves a column index as well as a name', () => {
const dcValidator = buildValidator(example_dqRules)
// 2 is SOME_CHAR in COLHEADERS - the grid hands the hook a prop index
// for some sources
expect(dcValidator.getCaseTransform(2)).toEqual('upper')
})
it('returns undefined for a column with no CASE rule', () => {
const dcValidator = buildValidator(example_dqRules)
expect(dcValidator.getCaseTransform('SOME_NUM')).toBeUndefined()
})
it('returns undefined for a column that is not in the grid', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'NOT_IN_THE_GRID',
RULE_TYPE: 'CASE',
RULE_VALUE: 'UPCASE',
X: 0
}
])
expect(dcValidator.getCaseTransform('NOT_IN_THE_GRID')).toBeUndefined()
})
it('returns undefined for a CASE rule value the validation does not apply', () => {
// dq-validation.ts only acts on UPCASE and LOWCASE; converting a value
// for any other rule value would be a transform the rule never asked for
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'CASE',
RULE_VALUE: 'TITLECASE',
X: 0
}
])
expect(dcValidator.getCaseTransform('SOME_CHAR_ANY')).toBeUndefined()
})
})
})
/** Minimal cols[] entry — only the fields rule ordering depends on. */
@@ -1,221 +0,0 @@
import {
applyCaseTransform,
CASE_HEADER_HINTS,
CaseTransform,
composeCaseHeaderTitle,
isCaseTransformSkipped,
resolveEnteredCaseTransform,
stripCaseHeaderTitle
} from './caseTransform'
describe('applyCaseTransform', () => {
it('upper-cases a string when the column rule is UPCASE', () => {
expect(applyCaseTransform('abc', 'upper')).toEqual('ABC')
expect(applyCaseTransform('Mixed Case 1', 'upper')).toEqual('MIXED CASE 1')
})
it('lower-cases a string when the column rule is LOWCASE', () => {
expect(applyCaseTransform('ABC', 'lower')).toEqual('abc')
expect(applyCaseTransform('Mixed Case 1', 'lower')).toEqual('mixed case 1')
})
it('leaves the value alone when the column carries no CASE rule', () => {
expect(applyCaseTransform('aBc', undefined)).toEqual('aBc')
})
it('leaves a value that is not a string alone', () => {
// a numeric column can still carry a CASE rule by mistake - it must not
// become the string '5'
expect(applyCaseTransform(5, 'upper')).toEqual(5)
expect(applyCaseTransform(5.5, 'lower')).toEqual(5.5)
})
it('leaves blank and missing values alone', () => {
expect(applyCaseTransform('', 'upper')).toEqual('')
expect(applyCaseTransform(null, 'upper')).toBeNull()
expect(applyCaseTransform(undefined, 'lower')).toBeUndefined()
// a SAS special missing is a character value on a character column, and
// stays exactly as typed
expect(applyCaseTransform('.a', 'upper')).toEqual('.A')
})
})
describe('isCaseTransformSkipped', () => {
it('skips a dataset handed back to the grid on load', () => {
expect(isCaseTransformSkipped('loadData')).toBeTrue()
})
it('skips undo and redo, which restore a value rather than enter one', () => {
expect(isCaseTransformSkipped('UndoRedo.undo')).toBeTrue()
expect(isCaseTransformSkipped('UndoRedo.redo')).toBeTrue()
})
it('skips the Revert menu item, which puts the service value back', () => {
expect(isCaseTransformSkipped('revert')).toBeTrue()
})
it('skips the computed writers, whose values nobody typed', () => {
// a resolved primary key has to keep the case the formula produced, and it
// is rewritten on every row event, so converting it would fight the pass
expect(isCaseTransformSkipped('resolvePkFormula')).toBeTrue()
// this one restores a live `=...` formula, which re-casing corrupts
expect(isCaseTransformSkipped('unescapeFormula')).toBeTrue()
})
it('converts a forced validation value on purpose', () => {
// a forced value is a configured default written as though entered, so it
// is converted to satisfy the column's own rule
expect(isCaseTransformSkipped('force_cell_validation_value')).toBeFalse()
})
it('does not skip a value the user enters', () => {
expect(isCaseTransformSkipped('edit')).toBeFalse()
expect(isCaseTransformSkipped('paste')).toBeFalse()
expect(isCaseTransformSkipped('autofill')).toBeFalse()
})
})
describe('resolveEnteredCaseTransform', () => {
const stub = (opts: {
readOnly?: boolean
formula?: boolean
transform?: CaseTransform
}) => ({
getRule: () => ({ readOnly: opts.readOnly }),
hasDqRules: (col: string, rules: string[]) =>
!!opts.formula && rules.includes('SOFTFORMULA'),
getCaseTransform: () => opts.transform
})
it('returns the transform for an editable CASE column', () => {
expect(
resolveEnteredCaseTransform('CODE', stub({ transform: 'upper' }))
).toEqual('upper')
})
it('refuses a read-only column, whose entries never reach the hook', () => {
expect(
resolveEnteredCaseTransform(
'CODE',
stub({ readOnly: true, transform: 'upper' })
)
).toBeUndefined()
})
it('refuses a formula column, which holds a live =... string', () => {
expect(
resolveEnteredCaseTransform(
'CODE',
stub({ formula: true, transform: 'upper' })
)
).toBeUndefined()
})
it('treats a derived column like any other, because the grid still takes entries', () => {
// the predicate has no notion of "derived": the grid only tints such a
// column, it does not block edits, so excluding it would stop converting
// values the editor converts today
expect(
resolveEnteredCaseTransform('CODE', stub({ transform: 'lower' }))
).toEqual('lower')
})
it('is undefined without a validator, a column name or a CASE rule', () => {
expect(resolveEnteredCaseTransform('CODE', undefined)).toBeUndefined()
expect(
resolveEnteredCaseTransform('', stub({ transform: 'upper' }))
).toBeUndefined()
expect(resolveEnteredCaseTransform('CODE', stub({}))).toBeUndefined()
})
})
describe('stripCaseHeaderTitle', () => {
it('removes the hint this module appends', () => {
expect(
stripCaseHeaderTitle(`Account Code; ${CASE_HEADER_HINTS.upper}`)
).toEqual('Account Code')
})
it('removes a hint when it is the whole title', () => {
expect(stripCaseHeaderTitle(CASE_HEADER_HINTS.lower)).toEqual('')
})
it('collapses repeated hints and the separator between them', () => {
const stacked = [
'Account Code',
CASE_HEADER_HINTS.upper,
CASE_HEADER_HINTS.upper
].join('; ')
expect(stripCaseHeaderTitle(stacked)).toEqual('Account Code')
})
it('leaves a title that merely uses the same words alone', () => {
// only the `; <hint>` form is ours; a derived label that happens to read
// like the sentence is not a hint we added
const label = 'Values are converted to upper case on entry (all codes)'
expect(stripCaseHeaderTitle(label)).toEqual(label)
})
it('is empty for an empty or missing title', () => {
expect(stripCaseHeaderTitle('')).toEqual('')
expect(stripCaseHeaderTitle(undefined)).toEqual('')
})
})
describe('composeCaseHeaderTitle', () => {
it('is the hint alone when the header has no title of its own', () => {
expect(composeCaseHeaderTitle('', 'upper')).toEqual(CASE_HEADER_HINTS.upper)
expect(composeCaseHeaderTitle(undefined, 'lower')).toEqual(
CASE_HEADER_HINTS.lower
)
})
it('appends the hint to a title the grid already set', () => {
expect(composeCaseHeaderTitle('Account Code', 'upper')).toEqual(
`Account Code; ${CASE_HEADER_HINTS.upper}`
)
})
it('does not stack the hint when the header is re-rendered', () => {
// afterGetColHeader runs on every render over the same th element, which is
// how the tooltip grew a copy per render
let title = composeCaseHeaderTitle('Account Code', 'upper')
for (let render = 0; render < 5; render++) {
title = composeCaseHeaderTitle(title, 'upper')
}
expect(title).toEqual(`Account Code; ${CASE_HEADER_HINTS.upper}`)
expect(title.split(CASE_HEADER_HINTS.upper).length - 1).toEqual(1)
})
it('replaces the hint when a metadata reload changes the rule', () => {
const upper = composeCaseHeaderTitle('Account Code', 'upper')
expect(composeCaseHeaderTitle(upper, 'lower')).toEqual(
`Account Code; ${CASE_HEADER_HINTS.lower}`
)
})
it('collapses a title that already carries several copies', () => {
const stacked = [
'Account Code',
CASE_HEADER_HINTS.upper,
CASE_HEADER_HINTS.upper,
CASE_HEADER_HINTS.upper
].join('; ')
expect(composeCaseHeaderTitle(stacked, 'upper')).toEqual(
`Account Code; ${CASE_HEADER_HINTS.upper}`
)
})
it('keeps a derived column label in front of the hint', () => {
const derived = 'Total value - derived variable, computed at read time'
expect(composeCaseHeaderTitle(derived, 'lower')).toEqual(
`${derived}; ${CASE_HEADER_HINTS.lower}`
)
})
})
@@ -1,160 +0,0 @@
/**
* The transform a `CASE` validation asks for, as the grid applies it on entry.
*
* `CASE=UPCASE` and `CASE=LOWCASE` are point-of-entry rules: a value in the
* other case is rejected by the validator, so the user is asked to retype it.
* The EDIT grid instead converts the pending value before it commits, so the
* typed text lands in the case the rule wants.
*/
export type CaseTransform = 'upper' | 'lower'
/**
* Change sources whose values must not be re-cased.
*
* - `loadData` is the grid re-sending its own dataset on every
* `updateSettings({data})` (initial render, the add-row flow, the Excel
* preview). Converting there would rewrite the values the service supplied
* and show rows as modified before the user has touched them.
* - `UndoRedo.undo` / `UndoRedo.redo` restore the value the cell held rather
* than entering a new one. Re-casing a restored value would hand back
* something different from what the user asked to get back - and for a
* backend value that does not satisfy the rule it would quietly replace it
* with one that does.
* - `revert` is the Revert menu item, which puts back the value the service
* supplied for that cell. Converting it would leave the cell modified
* against the unchanged baseline, i.e. the revert would not have reverted.
* - `resolvePkFormula` is the pass that writes a computed primary-key value
* back into its own cell. The value is already the one the formula
* produced; re-casing it would change a key that has to match the backend,
* and the pass would keep rewriting it on every row event.
* - `unescapeFormula` writes an auto-escaped value back as the live `=...`
* formula it came from. Re-casing a formula string corrupts it.
*
* `force_cell_validation_value` is deliberately NOT on this list: a forced
* value is a configured default written into a cell as though the user had
* entered it, so it is converted to satisfy the column's own rule rather than
* arriving pre-flagged as invalid.
*/
export const CASE_TRANSFORM_SKIPPED_SOURCES = [
'loadData',
'UndoRedo.undo',
'UndoRedo.redo',
'revert',
'resolvePkFormula',
'unescapeFormula'
]
export const isCaseTransformSkipped = (source: string): boolean =>
CASE_TRANSFORM_SKIPPED_SOURCES.includes(source)
/** The tooltip text a CASE column's header carries. */
export const CASE_HEADER_HINTS: Record<CaseTransform, string> = {
upper: 'Values are converted to upper case on entry',
lower: 'Values are converted to lower case on entry'
}
/**
* The parts of `DcValidator` the entry predicate needs, as a structural type so
* this module stays free of the validator (and the predicate stays testable
* without a grid).
*/
export interface CaseTransformSource {
getRule(col: string | number): { readOnly?: boolean } | undefined
hasDqRules(col: string, rules: string[]): boolean
getCaseTransform(col: string | number): CaseTransform | undefined
}
/**
* The case transform to apply to values *entered* into `colName`, or undefined
* when the column must neither convert them nor advertise that it does.
*
* One predicate for both the conversion (the beforeChange hook) and the header
* marker (afterGetColHeader), so a badge and tooltip cannot claim a conversion
* the hook refuses to make:
* - a HARDFORMULA or SOFTFORMULA column holds a live `=...` string, which
* re-casing corrupts (HARDFORMULA is read-only, SOFTFORMULA is editable and
* so does reach the hook);
* - a read-only cell never receives an entry. Kept as belt and braces: the
* grid refuses the edit, so the hook is not expected to see one.
*
* A derived column is deliberately NOT excluded: the grid does not block
* entries there (it only tints the header and cell), so excluding it would stop
* converting values that the previous behaviour converted.
*/
export const resolveEnteredCaseTransform = (
colName: string,
validator: CaseTransformSource | undefined
): CaseTransform | undefined => {
if (!colName || !validator) return undefined
if (validator.getRule(colName)?.readOnly) return undefined
if (validator.hasDqRules(colName, ['HARDFORMULA', 'SOFTFORMULA'])) {
return undefined
}
return validator.getCaseTransform(colName)
}
/**
* A column header's title with any case hint removed.
*
* Handles only the form `composeCaseHeaderTitle` produces - `; <hint>` - so a
* title that merely contains the same words (a derived variable's label, say)
* is left alone. Used when a column stops carrying the rule or stops being
* convertible: the header element is reused, so the tooltip would otherwise go
* on claiming a conversion that no longer happens.
*/
export const stripCaseHeaderTitle = (title: string | undefined): string => {
const hints = Object.values(CASE_HEADER_HINTS)
let base = (title || '').trim()
for (const hint of hints) {
base = base.split(`; ${hint}`).join('')
}
// a title that is nothing but a hint has no base to separate it from the
// separator, so it is all hint - which is what a header with no title of its
// own looks like after one compose
if (hints.includes(base.trim())) return ''
return base.replace(/;\s*$/, '').trim()
}
/**
* Composes a column header's title with its case hint, idempotently.
*
* `afterGetColHeader` runs on every render and Handsontable reuses the header
* element it is handed, so appending the hint each time grew the tooltip by a
* copy per render (one after the first render, two after the next, and so on).
* Stripping the known hints before appending makes a repeated call a no-op and
* lets the hint change (a metadata reload that swaps UPCASE for LOWCASE)
* without leaving the old sentence behind.
*/
export const composeCaseHeaderTitle = (
title: string | undefined,
transform: CaseTransform
): string => {
const base = stripCaseHeaderTitle(title)
const hint = CASE_HEADER_HINTS[transform]
return base ? `${base}; ${hint}` : hint
}
/**
* Applies the column's case transform to a pending cell value.
*
* Only a string is converted: a numeric value, a SAS missing, `null`,
* `undefined` and a blank all pass through untouched, so the transform can be
* applied to every change in a batch without inspecting the column type.
*
* Returns the value to commit - the caller assigns it back onto the pending
* change, rather than writing the cell, so the grid keeps its cursor,
* selection and undo history.
*/
export const applyCaseTransform = (
value: unknown,
transform: CaseTransform | undefined
): unknown => {
if (!transform || typeof value !== 'string') return value
return transform === 'upper' ? value.toUpperCase() : value.toLowerCase()
}
@@ -1,65 +0,0 @@
import Handsontable from 'handsontable'
import { buildExportMenuItem, EXPORT_MENU_KEY } from './hot-export.util'
/**
* Handsontable's ExportFile plugin contributes a context-menu entry under the
* reserved key `export_file`, and the ContextMenu plugin folds that entry over
* whatever the grid supplied at the same key. The plugin's own `hidden()`
* returns true unless the grid carries an `exportFile` setting, so a grid that
* configures its export under that reserved key renders no item at all.
*
* These specs pin both halves: the reserved key loses the item, and a key of
* the app's own keeps it. Run against a real grid because the merge happens in
* the ContextMenu plugin at menu-build time, not in our item factory.
*/
describe('export context-menu item', () => {
let container: HTMLDivElement | undefined
let hot: Handsontable | undefined
/** Builds a grid whose only export entry sits under `key`, opens the menu. */
const openMenuWith = (key: string): any => {
container = document.createElement('div')
document.body.appendChild(container)
hot = new Handsontable(container, {
data: [
['a', 1],
['b', 2]
],
colHeaders: ['A', 'B'],
licenseKey: 'non-commercial-and-evaluation',
contextMenu: {
items: {
[key]: buildExportMenuItem(() => 'export'),
copy_with_column_headers: {}
}
}
} as any)
const plugin: any = hot.getPlugin('contextMenu')
plugin.open({ left: 0, top: 0 })
return plugin
}
afterEach(() => {
if (hot && !hot.isDestroyed) hot.destroy()
hot = undefined
container?.remove()
container = undefined
})
it('is dropped when configured under the exportFile plugin reserved key', () => {
const plugin = openMenuWith('export_file')
expect(plugin.menu.getItemPositionByKey('export_file')).toBe(-1)
})
it('is rendered under the key the app exports', () => {
expect(EXPORT_MENU_KEY).not.toBe('export_file')
const plugin = openMenuWith(EXPORT_MENU_KEY)
expect(plugin.menu.getItemPositionByKey(EXPORT_MENU_KEY)).toBeGreaterThan(
-1
)
})
})
@@ -1,213 +0,0 @@
import Handsontable from 'handsontable'
import {
buildGridPdfInput,
collectGridTable,
exportGrid,
isRangeEmpty,
getExportRange,
buildExportMenuItem,
EXPORT_MENU_KEY
} from './hot-export.util'
/**
* The grid-reading side of the PDF export, against a real Handsontable
* instance so `getColHeader`, `getSelectedRangeLast` and the hiddenColumns
* plugin behave as they do in the viewer and editor.
*/
describe('hot-export grid extraction', () => {
let container: HTMLDivElement
let hot: Handsontable
const settings = (overrides: Record<string, any> = {}) => ({
data: [
['DELETE?', 'A1', '10.5'],
['', 'A2', '20.25'],
['', 'A3', null]
],
colHeaders: ['Delete?', 'ACCOUNT', 'BALANCE'],
rowHeaders: false,
licenseKey: 'non-commercial-and-evaluation',
...overrides
})
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
if (hot && !hot.isDestroyed) hot.destroy()
hot = undefined as any
container.remove()
})
it('reads headers and every row when nothing is selected', () => {
hot = new Handsontable(container, settings())
hot.render()
const table = collectGridTable(hot)
expect(table.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
expect(table.rows.length).toBe(3)
expect(table.rows[0]).toEqual(['DELETE?', 'A1', '10.5'])
expect(table.rows[2][2]).toBeNull()
})
it('drops the leading housekeeping column when asked', () => {
hot = new Handsontable(container, settings())
hot.render()
const table = collectGridTable(hot, 1)
expect(table.headers).toEqual(['ACCOUNT', 'BALANCE'])
expect(table.rows[0]).toEqual(['A1', '10.5'])
})
it('excludes columns hidden in the grid', () => {
hot = new Handsontable(
container,
settings({ hiddenColumns: { columns: [2], indicators: false } })
)
hot.render()
const table = collectGridTable(hot)
expect(table.headers).toEqual(['Delete?', 'ACCOUNT'])
expect(table.rows.map((row) => row.length)).toEqual([2, 2, 2])
})
it('survives a hiddenColumns plugin that cannot report (regression)', () => {
// getPlugin() hands back the instance even when the plugin is off, and its
// isHidden() then dereferences a map that was never created - seen in CI on
// grids with no hiddenColumns setting at all.
hot = new Handsontable(container, settings())
hot.render()
spyOn(hot, 'getPlugin').and.returnValue({
enabled: true,
isHidden: () => {
throw new TypeError(
"Cannot read properties of null (reading 'getValueAtIndex')"
)
}
} as any)
const table = collectGridTable(hot)
expect(table.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
expect(table.rows.length).toBe(3)
})
it('limits the export to a multi-cell selection', () => {
hot = new Handsontable(container, settings())
hot.render()
hot.selectCells([[1, 1, 2, 2]])
const range = getExportRange(hot)
const table = collectGridTable(hot)
expect(range).toEqual({ top: 1, left: 1, bottom: 2, right: 2 })
expect(table.headers).toEqual(['ACCOUNT', 'BALANCE'])
expect(table.rows).toEqual([
['A2', '20.25'],
['A3', null]
])
})
it('exports nothing when the selection is inside the skipped columns', () => {
hot = new Handsontable(container, settings())
hot.render()
// Rows 0-2 of column 0 only: the Delete? column, which the caller skips.
hot.selectCells([[0, 0, 2, 0]])
const range = getExportRange(hot, 1)
expect(isRangeEmpty(range)).toBeTrue()
expect(collectGridTable(hot, 1).headers).toEqual([])
expect(buildGridPdfInput(hot, 'WORK.TEST', 1)).toBeNull()
})
it('treats a single-cell cursor as "export everything"', () => {
hot = new Handsontable(container, settings())
hot.render()
hot.selectCell(1, 1)
expect(collectGridTable(hot).rows.length).toBe(3)
})
it('never starts inside the skipped housekeeping columns', () => {
hot = new Handsontable(container, settings())
hot.render()
// A selection that begins in the Delete? column still exports from column 1.
hot.selectCells([[0, 0, 2, 2]])
const range = getExportRange(hot, 1)
expect(range.left).toBe(1)
expect(collectGridTable(hot, 1).headers).toEqual(['ACCOUNT', 'BALANCE'])
})
it('keeps the skipped column out of CSV/Excel exports (regression)', async () => {
// The exportFile plugin is not enabled in these grids, so stand in for it
// and read the options the export passes through.
hot = new Handsontable(container, settings())
hot.render()
const downloadFile = jasmine.createSpy('downloadFile')
const getPlugin = hot.getPlugin.bind(hot)
spyOn(hot, 'getPlugin').and.callFake((name: string) =>
name === 'exportFile' ? ({ downloadFile } as any) : getPlugin(name as any)
)
await exportGrid(hot, 'csv', 'WORK.TEST', 1)
expect(downloadFile).toHaveBeenCalledTimes(1)
const opts = downloadFile.calls.mostRecent().args[1]
// Column 0 is the Delete? column; the export must not start at it, and the
// plugin must not be left to export the whole grid.
expect(opts['range']).toEqual([0, 1, 2, 2])
})
it('stops reading rows at the cap instead of collecting them all', () => {
hot = new Handsontable(container, settings())
hot.render()
const read = spyOn(hot, 'getDataAtCell').and.callThrough()
const table = collectGridTable(hot, 0, 2)
expect(table.rows.length).toBe(2)
expect(read).toHaveBeenCalledTimes(2 * 3) // 2 rows x 3 columns
})
it('still produces a PDF for a grid with no rows', () => {
// The real grids declare their columns, so an empty grid still has a
// schema - which is the case this guards. Without `columns`, HOT reports
// zero columns for `data: []` and there would be no table to describe.
hot = new Handsontable(
container,
settings({
data: [],
columns: [{ data: 0 }, { data: 1 }, { data: 2 }]
})
)
hot.render()
const input = buildGridPdfInput(hot, 'WORK.EMPTY')
expect(input).not.toBeNull()
expect(input!.headers).toEqual(['Delete?', 'ACCOUNT', 'BALANCE'])
expect(input!.rows).toEqual([])
expect(input!.subtitle).toBe('0 rows x 3 columns')
})
it('offers PDF alongside CSV and Excel in the context menu', () => {
const item: any = buildExportMenuItem(() => 'WORK.TEST')
const keys = item.submenu.items.map((sub: any) => sub.key)
const names = item.submenu.items.map((sub: any) => sub.name)
expect(keys).toEqual([
`${EXPORT_MENU_KEY}:csv`,
`${EXPORT_MENU_KEY}:xlsx`,
`${EXPORT_MENU_KEY}:pdf`
])
expect(names).toContain('PDF (.pdf)')
})
})
@@ -1,193 +1,7 @@
import Handsontable from 'handsontable'
import { MenuItemConfig } from 'handsontable/plugins/contextMenu'
import { downloadTablePdf, TablePdfInput } from './pdf-export.util'
export type HotExportFormat = 'csv' | 'xlsx' | 'pdf'
/**
* Row cap for the PDF export. Unlike CSV/Excel, a PDF is laid out page by page
* in the browser, so an unbounded grid would freeze the tab and produce a
* document nobody reads; the export says so in its subtitle when it truncates.
*/
const MAX_PDF_ROWS = 5000
/**
* Visual (0-based, inclusive) block of the grid to export: honours the current
* selection when there is one, otherwise the whole grid. `left` never reaches
* into the leading housekeeping columns the caller asked to skip, and a
* selection that lies entirely inside those columns yields an empty range
* (`right < left`) rather than being shifted sideways into a column the user
* did not select.
*/
export interface GridRange {
top: number
left: number
bottom: number
right: number
}
/** True when the range covers no column, so there is nothing to export. */
export function isRangeEmpty(range: GridRange): boolean {
return range.right < range.left
}
/**
* Works out which block of cells an export should cover.
*
* HOT's own export item only honours a selection spanning more than one cell:
* a right-click places a single-cell cursor and the corner click is select-all
* (negative coordinates), and both of those mean "export the whole table".
*/
export function getExportRange(
hot: Handsontable,
skipLeadingCols = 0
): GridRange {
const sel = hot.getSelectedRangeLast()
const lastRow = hot.countRows() - 1
const lastCol = hot.countCols() - 1
let top = 0
let left = skipLeadingCols
let bottom = lastRow
let right = lastCol
// sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
// but a range returned by getSelectedRangeLast() always has real coordinates.
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
bottom = Math.max(sel.from.row!, sel.to.row!)
const selectedLeft = Math.min(sel.from.col!, sel.to.col!)
const selectedRight = Math.max(sel.from.col!, sel.to.col!)
left = Math.max(skipLeadingCols, selectedLeft)
right = selectedRight
// Selecting only the skipped leading column(s) leaves nothing to export;
// `right` below `left` is the empty range, never a sideways shift.
if (right < left) right = left - 1
}
return { top, left, bottom, right }
}
/**
* Columns hidden in the grid (DQ rules in the viewer, filters elsewhere).
*
* `getPlugin()` returns the plugin instance even when it is not switched on, and
* `isHidden()` reads a map that only exists once the plugin has been enabled -
* so ask the enabled flag first, and treat any remaining failure as "visible"
* rather than losing the whole export.
*/
function isHiddenColumn(hot: Handsontable, visualColumn: number): boolean {
const plugin = hot.getPlugin('hiddenColumns') as any
if (
!plugin ||
plugin.enabled !== true ||
typeof plugin.isHidden !== 'function'
) {
return false
}
try {
return plugin.isHidden(visualColumn) === true
} catch {
return false
}
}
export interface GridTable {
headers: string[]
rows: (string | number | null)[][]
}
/**
* Reads the exportable slice of a grid into plain headers and rows, the same
* way the CSV export sees it: raw cell values (not rendered text), hidden
* columns dropped, leading housekeeping columns excluded.
*
* @param maxRows Stop after this many rows. The limit is applied to the range
* before any cell is read, so a capped export never pays for the rows it
* discards.
*/
export function collectGridTable(
hot: Handsontable,
skipLeadingCols = 0,
maxRows = Infinity
): GridTable {
const range = getExportRange(hot, skipLeadingCols)
const colHeaders = hot.getColHeader()
const headerArray = Array.isArray(colHeaders) ? (colHeaders as string[]) : []
const columns: number[] = []
for (let col = Math.max(0, range.left); col <= range.right; col++) {
if (!isHiddenColumn(hot, col)) columns.push(col)
}
const headers = columns.map((col) => {
const header = headerArray[col]
return header === null || header === undefined ? '' : String(header)
})
const firstRow = Math.max(0, range.top)
const lastRow = Math.min(range.bottom, firstRow + maxRows - 1)
const rows: (string | number | null)[][] = []
for (let row = firstRow; row <= lastRow; row++) {
rows.push(
columns.map(
(col) => hot.getDataAtCell(row, col) as string | number | null
)
)
}
return { headers, rows }
}
/**
* Client-side PDF export of the grid's current view. Same "what you see"
* contract as the CSV/Excel export: the current selection if there is one,
* otherwise the whole grid, hidden columns excluded.
*/
export function exportGridPdf(
hot: Handsontable,
filename: string,
skipLeadingCols = 0
): void {
const input = buildGridPdfInput(hot, filename, skipLeadingCols)
// Without columns there is no table to describe; a grid with no rows still
// makes a page, with its headers and a "0 rows" subtitle, rather than the
// click doing nothing at all.
if (!input) return
downloadTablePdf(input, filename || 'export')
}
/**
* Builds the PDF input for a grid, or `null` when the grid has no exportable
* columns. Split out from {@link exportGridPdf} so the "no rows" case can be
* asserted without touching the browser's download plumbing.
*/
export function buildGridPdfInput(
hot: Handsontable,
filename: string,
skipLeadingCols = 0
): TablePdfInput | null {
const range = getExportRange(hot, skipLeadingCols)
if (isRangeEmpty(range)) return null
const availableRows = Math.max(0, range.bottom - range.top + 1)
// The cap is applied inside collectGridTable so the discarded rows are never
// read; the subtitle still needs the real total.
const { headers, rows } = collectGridTable(hot, skipLeadingCols, MAX_PDF_ROWS)
if (!headers.length) return null
const truncated = availableRows > rows.length
return {
title: filename || 'export',
subtitle:
`${rows.length} row${rows.length === 1 ? '' : 's'} x ` +
`${headers.length} column${headers.length === 1 ? '' : 's'}` +
(truncated ? ` (first ${rows.length} of ${availableRows} rows)` : ''),
headers,
rows
}
}
export type HotExportFormat = 'csv' | 'xlsx'
/**
* Cell types whose values must be exported as raw stored strings rather than
@@ -211,7 +25,7 @@ const RAW_EXPORT_TYPES = new Set([
* from any server-side (SAS) download.
*
* @param hot Live Handsontable instance.
* @param format `'csv'`, `'xlsx'` or `'pdf'`.
* @param format `'csv'` or `'xlsx'`.
* @param filename File name (without extension).
* @param skipLeadingCols Number of leading UI/housekeeping columns to exclude
* (e.g. the editor's `Delete?` column at index 0).
@@ -222,11 +36,6 @@ export async function exportGrid(
filename: string,
skipLeadingCols = 0
): Promise<void> {
if (format === 'pdf') {
exportGridPdf(hot, filename, skipLeadingCols)
return
}
const plugin = hot.getPlugin('exportFile')
const opts: Record<string, any> = {
@@ -236,20 +45,26 @@ export async function exportGrid(
// Selection-aware, contiguous range (visual indexes). Hidden columns are
// excluded automatically (exportHiddenColumns defaults to false).
const range = getExportRange(hot, skipLeadingCols)
if (isRangeEmpty(range)) return
// "Whole grid" means the export starts at visual column 0 - not at
// `skipLeadingCols`, or a caller that skips a leading housekeeping column
// would look like a whole-grid export and the plugin would put that column
// back in. Leaving `range` unset lets the plugin export the whole grid
// exactly as its own menu item does; it is explicit whenever it is narrower.
const isWholeGrid =
range.top === 0 &&
range.left === 0 &&
range.bottom === hot.countRows() - 1 &&
range.right === hot.countCols() - 1
if (!isWholeGrid) {
opts['range'] = [range.top, range.left, range.bottom, range.right]
const sel = hot.getSelectedRangeLast()
const lastRow = hot.countRows() - 1
const lastCol = hot.countCols() - 1
// Mirror HOT's own export item: only honor a selection that spans more than
// one cell. A right-click places a single-cell cursor, and the corner click
// is select-all (negative coords) — both mean "export the whole table".
// sel.from/to.row/col are typed nullable (CellCoords allows an unset state),
// but a range returned by getSelectedRangeLast() always has real coordinates.
const isCornerSelectAll = !!sel && sel.from.row! < 0 && sel.from.col! < 0
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
const top = Math.max(0, Math.min(sel.from.row!, sel.to.row!))
const left = Math.max(0, Math.min(sel.from.col!, sel.to.col!))
const bottom = Math.max(sel.from.row!, sel.to.row!)
const right = Math.max(sel.from.col!, sel.to.col!)
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
}
if (!opts['range'] && skipLeadingCols > 0) {
opts['range'] = [0, skipLeadingCols, lastRow, lastCol]
}
if (format === 'csv') {
@@ -311,22 +126,9 @@ export async function exportGrid(
}
/**
* Context-menu key for the export submenu.
*
* Deliberately NOT `export_file`: that key belongs to Handsontable's ExportFile
* plugin, and ContextMenu folds the plugin's own entry over whatever the grid
* supplies at the same key (`ItemsFactory.setPredefinedItems`). The plugin's
* entry carries `hidden() { return this.getSettings().exportFile === undefined }`,
* so on a grid without an `exportFile` setting - every grid in this app - the
* item is hidden and the whole submenu disappears, taking the PDF export with
* it. `export-menu.spec.ts` pins both halves of that behaviour.
*/
export const EXPORT_MENU_KEY = 'dc_export'
/**
* Builds a reusable export context-menu item (CSV / Excel / PDF submenu) that
* Builds a reusable `export_file` context-menu item (CSV / Excel submenu) that
* exports the given grid via {@link exportGrid}. Add it to any HOT context-menu
* `items` map under {@link EXPORT_MENU_KEY}.
* `items` map under the `export_file` key.
*
* @param getFilename Returns the export file name at click time.
* @param skipLeadingCols Leading UI columns to exclude (see {@link exportGrid}).
@@ -345,25 +147,25 @@ export function buildExportMenuItem(
return {
name: 'Export',
// The exportFile plugin registers a predefined item under the same
// `export_file` key whose `hidden()` returns true unless an `exportFile`
// setting exists. HOT shallow-merges our config over it, so we must
// explicitly override `hidden` to keep the item visible.
hidden: false,
submenu: {
// Submenu keys use the documented "parent_key:sub_key" form. The array
// form is what HOT's own menu uses at runtime; cast to satisfy the
// (stricter) MenuConfig typing.
// form is what HOT's own exportFile plugin uses at runtime; cast to
// satisfy the (stricter) MenuConfig typing.
items: [
{
key: `${EXPORT_MENU_KEY}:csv`,
key: 'export_file:csv',
name: 'CSV',
callback: makeCb('csv')
},
{
key: `${EXPORT_MENU_KEY}:xlsx`,
key: 'export_file:xlsx',
name: 'Excel (.xlsx)',
callback: makeCb('xlsx')
},
{
key: `${EXPORT_MENU_KEY}:pdf`,
name: 'PDF (.pdf)',
callback: makeCb('pdf')
}
] as any
}
@@ -1,259 +0,0 @@
import {
createTablePdf,
downloadTablePdf,
TablePdfInput
} from './pdf-export.util'
/** PDF bytes are Latin-1 by construction, so decode them the same way. */
function decode(bytes: Uint8Array): string {
let text = ''
for (const byte of bytes) text += String.fromCharCode(byte)
return text
}
function build(overrides: Partial<TablePdfInput> = {}): string {
return decode(
createTablePdf({
title: 'WORK.CUSTOMERS',
subtitle: '2 rows x 2 columns',
headers: ['ACCOUNT', 'BALANCE'],
rows: [
['A1', '1.25'],
['A2', '2.50']
],
generatedAt: new Date('2026-11-01T19:00:00Z'),
...overrides
})
)
}
/** The `(literal) Tj` operands of a content stream, unescaped enough to measure. */
function literals(pdf: string): string[] {
return Array.from(pdf.matchAll(/\((.*?)\) Tj/g)).map((match) => match[1])
}
describe('downloadTablePdf', () => {
it('releases the blob URL after the download has started, not during it', () => {
// Firefox and Safari read the blob asynchronously, so revoking in the same
// task as the click cancels the download.
jasmine.clock().install()
const click = spyOn(HTMLAnchorElement.prototype, 'click')
spyOn(URL, 'createObjectURL').and.returnValue('blob:test-url')
const revoke = spyOn(URL, 'revokeObjectURL')
downloadTablePdf({ title: 'T', headers: ['A'], rows: [['1']] }, 'export')
expect(click).toHaveBeenCalledTimes(1)
expect(revoke).not.toHaveBeenCalled()
jasmine.clock().tick(1000)
expect(revoke).toHaveBeenCalledWith('blob:test-url')
jasmine.clock().uninstall()
})
})
describe('createTablePdf', () => {
it('produces a structurally valid PDF with a working xref', () => {
const pdf = build()
expect(pdf.startsWith('%PDF-1.4')).toBeTrue()
expect(pdf.trimEnd().endsWith('%%EOF')).toBeTrue()
const startxref = Number(pdf.match(/startxref\n(\d+)/)![1])
expect(pdf.slice(startxref, startxref + 4)).toBe('xref')
// Every object listed in the xref must actually start at the offset given.
const xref = pdf.slice(startxref).split('\n')
const entryCount = Number(xref[1].split(' ')[1])
for (let id = 1; id < entryCount; id++) {
const offset = Number(xref[2 + id].slice(0, 10))
expect(pdf.slice(offset, offset + `${id} 0 obj`.length))
.withContext(`object ${id}`)
.toBe(`${id} 0 obj`)
}
})
it('declares one /Type /Page per page and a matching /Count', () => {
const pdf = build()
const pageObjects = (pdf.match(/\/Type \/Page[^s]/g) || []).length
expect(pdf).toContain('/Count 1')
expect(pageObjects).toBe(1)
})
it('prints the title, subtitle and a generated stamp', () => {
const pdf = build()
expect(literals(pdf)).toContain('WORK.CUSTOMERS')
expect(literals(pdf)).toContain('2 rows x 2 columns')
expect(literals(pdf)).toContain('Generated 2026-11-01 19:00')
})
it('prints every cell value', () => {
const pdf = build()
// BALANCE is numeric, so its values are padded to the column's right edge.
const values = literals(pdf).map((literal) => literal.trim())
expect(values).toEqual(
jasmine.arrayContaining(['A1', 'A2', '1.25', '2.50'])
)
})
it('escapes the PDF string delimiters', () => {
const pdf = build({
headers: ['NOTE'],
rows: [['a (b) c \\ d']]
})
// The literal must carry the backslash escapes, not raw delimiters.
expect(pdf).toContain('(a \\(b\\) c \\\\ d) Tj')
})
it('maps text to WinAnsi where it exists and falls back to ? elsewhere', () => {
const pdf = build({
headers: ['NAME', 'COST', 'OTHER'],
rows: [['M\u00fcller', '\u20ac5', '\u4f8b\u3048']]
})
const values = literals(pdf).join('|')
expect(values).toContain('M\\374ller') // u-umlaut: octal for cp1252 0xFC
expect(values).toContain('\\2005') // euro sign: cp1252 0x80
expect(values).toContain('??') // outside cp1252
})
it('right-aligns numeric columns and left-aligns the rest', () => {
const pdf = build({
headers: ['NAME', 'AMOUNT'],
rows: [
['x', '7.5'],
['long-name', '1234.25']
]
})
const ops = literals(pdf)
// AMOUNT is 7 characters wide (its widest value), so 7.5 is padded to the
// right edge; NAME is left-aligned, so x is unpadded while the widest value
// fills the column.
expect(ops).toContain(' 7.5')
expect(ops).toContain('1234.25')
expect(ops).toContain('x')
expect(ops).toContain('long-name')
})
it('wraps a value too long for its column instead of overflowing it', () => {
const word = 'X'.repeat(140)
const pdf = build({ headers: ['ONECOL'], rows: [[word]] })
const lines = literals(pdf).filter((line) => /^X+$/.test(line))
expect(lines.length).toBeGreaterThan(1)
expect(lines.join('')).toBe(word)
for (const line of lines) expect(line.length).toBeLessThanOrEqual(48)
})
it('keeps a multi-line value on separate lines', () => {
const pdf = build({ headers: ['ADDR'], rows: [['LINE1', 'LINE2']] })
const pdfMulti = build({ headers: ['ADDR'], rows: [['LINE1\nLINE2']] })
expect(literals(pdfMulti)).toEqual(
jasmine.arrayContaining(['LINE1', 'LINE2'])
)
expect(literals(pdfMulti)).not.toEqual(literals(pdf))
})
it('paginates long tables and repeats the column headers on every page', () => {
const rows = Array.from({ length: 400 }, (_, index) => [
`row${index}`,
index
])
const pdf = build({ headers: ['NAME', 'ID'], rows })
const pageCount = Number(pdf.match(/\/Count (\d+)/)![1])
expect(pageCount).toBeGreaterThan(1)
const headerAppearances = literals(pdf).filter(
(op) => op.trim() === 'NAME' || op.trim() === 'ID'
)
expect(headerAppearances.length).toBe(pageCount * 2)
// Footers are numbered from 1 to the total page count.
expect(literals(pdf)).toContain(`Page 1 of ${pageCount}`)
expect(literals(pdf)).toContain(`Page ${pageCount} of ${pageCount}`)
})
it('shrinks the body font rather than truncating headers on a 20-column table', () => {
const columnCount = 20
const headers = Array.from(
{ length: columnCount },
(_, index) => `COLUMN_${String(index + 1).padStart(2, '0')}`
)
const rows = [
Array.from({ length: columnCount }, (_, index) => `value ${index}`)
]
const pdf = build({ headers, rows })
const sizes = Array.from(pdf.matchAll(/\/F1 ([\d.]+) Tf/g)).map((m) =>
Number(m[1])
)
// Every header survives intact, and the page is won back with font size.
expect(literals(pdf)).toContain('COLUMN_01')
expect(literals(pdf)).toContain('COLUMN_20')
expect(Math.min(...sizes)).toBeLessThan(7)
})
it('never places text outside the printable area', () => {
const headers = Array.from({ length: 60 }, (_, index) => `COL_${index}`)
const rows = [Array.from({ length: 60 }, (_, index) => `v${index}`)]
const pdf = build({ headers, rows })
const starts = Array.from(pdf.matchAll(/([\d.]+) [\d.-]+ Td/g)).map((m) =>
Number(m[1])
)
// 842 - 24 margin, plus a point of rounding slack.
expect(Math.max(...starts)).toBeLessThanOrEqual(819)
})
it('says so when the columns cannot fit even at the smallest font', () => {
const columnCount = 300
const headers = Array.from(
{ length: columnCount },
(_, index) => `HEADER_${index}`
)
const rows = [
Array.from({ length: columnCount }, (_, index) => `v${index}`)
]
const pdf = build({ headers, rows })
const note = literals(pdf).find((literal) =>
/^Showing the first \d+ of 300 columns$/.test(literal)
)
expect(note).toBeTruthy()
expect(literals(pdf)).not.toContain('HEADER_299')
})
it('clamps a row taller than the page and marks the omission', () => {
// 20k characters at a 48-character column is ~420 wrapped lines, far more
// than a page holds; the tail must be marked, not drawn off the page.
const value = Array.from({ length: 4000 }, (_, i) => `w${i}`).join(' ')
const pdf = build({ headers: ['LONG'], rows: [[value]] })
const lines = literals(pdf)
expect(lines.some((line) => line.trimEnd().endsWith('...'))).toBeTrue()
// The clamped row must fit the page it starts on, not spill onto its own.
expect(pdf).toContain('/Count 1')
// Nothing is drawn below the bottom margin (24pt) or above the page.
const tops = Array.from(pdf.matchAll(/[\d.]+ ([\d.-]+) Td/g)).map((m) =>
Number(m[1])
)
expect(Math.min(...tops)).toBeGreaterThanOrEqual(24)
expect(Math.max(...tops)).toBeLessThanOrEqual(595 - 24)
})
it('renders an empty table as a single valid page', () => {
const pdf = build({ headers: ['ONLY'], rows: [] })
expect(pdf).toContain('/Count 1')
expect(literals(pdf)).toContain('ONLY')
})
})
Loaded 100 of 428 files, more files were not shown because too many files have changed in this diff. Show more