Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92ff21b8f5 | ||
|
|
78b7de7120 | ||
|
|
6e0866d876 | ||
|
|
01d54041a2 | ||
|
|
0e0e79ef63 | ||
|
|
112e8e4b8b | ||
|
|
b5fe46931f | ||
|
|
5f89314aaf | ||
|
|
a6cfb12579 | ||
|
|
acf44dae74 | ||
|
|
712f74db6d | ||
|
|
d3f1b2ac0a | ||
|
|
7a5286fca8 | ||
|
|
25ae900f06 | ||
|
|
7133c97ad4 | ||
|
|
da8e7d343c | ||
|
|
88616c8f62 | ||
|
|
68dc790698 | ||
|
|
8646381044 | ||
|
|
caa58771bf | ||
|
|
67631133f9 | ||
|
|
f1ba6362cc | ||
|
|
755083bb49 | ||
|
|
0e74ab58a1 | ||
|
|
c9119101a4 | ||
|
|
676cdf6fdd | ||
|
|
88cc68e2f9 | ||
|
|
bafbe201ce | ||
|
|
109711b9a0 | ||
|
|
6bf7f9ba0d | ||
|
|
205adf0a2b | ||
|
|
f3d1eb11d1 | ||
|
|
e07246ac68 | ||
|
|
39e8ffdbdd | ||
|
|
6f7f2d189f | ||
|
|
5c646913e4 | ||
|
|
912fae448b | ||
|
|
eef91ce989 |
No files matched your search
@@ -46,8 +46,12 @@ 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` 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.
|
||||
`makedata` builds only what a SAS deployment builds (the control library);
|
||||
`createdemodata` adds the Cypress fixtures and refuses to run without it.
|
||||
The payload asks for `OUTFMT=JSON`, so both calls exit non-zero on a failed
|
||||
seed rather than reporting `invalid Json string`.
|
||||
|
||||
4. **Frontend.** Point the `sasjs` tag in `client/src/index.html` at the mock backend (`serverUrl="http://localhost:5000"`, `appLoc="/Public/app/dc"`, `serverType="SASJS"` - a local-only edit, the repo default is `serverUrl=""`), then `npx ng serve --host 0.0.0.0 --port 4200`. `client/cypress.env.json` needs `username`/`password` entries; any values work, `loginAndUpdateValidKey()` no-ops when there is no login form.
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
# 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
|
||||
|
||||
@@ -42,8 +49,12 @@ PATHS='client/|sas/mocks/|sas/package\.json|\.gitea/scripts/|package-lock\.json|
|
||||
# --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 grep -qE "^(${PATHS})"; then echo true; else echo false; fi
|
||||
if served_changes | grep -qE "^(${PATHS})"; then echo true; else echo false; fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -64,12 +75,12 @@ if ! CHANGED="$(git diff --name-only "refs/remotes/origin/${BASE}" HEAD)"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if grep -qE "^(${PATHS})" <<<"$CHANGED"; then
|
||||
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/ or .gitea/scripts/ changed"
|
||||
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"
|
||||
@@ -26,7 +26,8 @@ check() { # <expected> <path>...
|
||||
# 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/makedata.js
|
||||
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
|
||||
@@ -43,6 +44,14 @@ check false .gitea/workflows/lighthouse.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
|
||||
|
||||
@@ -373,6 +373,28 @@ jobs:
|
||||
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:
|
||||
@@ -464,12 +486,17 @@ jobs:
|
||||
# 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).
|
||||
# makedata replies with HTML (it is normally called as a URL redirect),
|
||||
# which the CLI reports as "invalid Json string" - ignore that.
|
||||
# Seed the DC database (mock data files on the drive). The payload
|
||||
# asks for OUTFMT=JSON, so the CLI parses the response and a failed
|
||||
# seed fails the job - createdemodata runs next and would only report
|
||||
# a missing control library.
|
||||
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
|
||||
# not the cwd (sas/mocks/sasjs).
|
||||
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
|
||||
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
|
||||
|
||||
- name: Prepare and run frontend and cypress
|
||||
if: steps.changes.outputs.client != 'false'
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.5.0'
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
typedoc:
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
# marketing site, which is why it must not be the target.
|
||||
- name: Deploy SAS docs
|
||||
env:
|
||||
SURFER_PASSWORD: ${{ secrets.CODE_DATACONTROLLER_IO }}
|
||||
SURFER_PASSWORD: ${{ secrets.WEBDOC_DATACONTROLLER_IO }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
default: ''
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.5.0'
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
env:
|
||||
# the server the mocked services and app tests run against - bump deliberately
|
||||
SASJS_SERVER_VERSION: '3.14.0'
|
||||
NODE_VERSION: '24.5.0'
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
Build-production-and-ng-test:
|
||||
@@ -209,12 +209,17 @@ jobs:
|
||||
# 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).
|
||||
# makedata replies with HTML (it is normally called as a URL redirect),
|
||||
# which the CLI reports as "invalid Json string" - ignore that.
|
||||
# Seed the DC database (mock data files on the drive). The payload
|
||||
# asks for OUTFMT=JSON, so the CLI parses the response and a failed
|
||||
# seed fails the job - createdemodata runs next and would only report
|
||||
# a missing control library.
|
||||
# NOTE: -d paths resolve from the sasjs project root (sas/mocks),
|
||||
# not the cwd (sas/mocks/sasjs).
|
||||
sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true
|
||||
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
|
||||
|
||||
- name: Prepare and run frontend and cypress
|
||||
env:
|
||||
@@ -237,20 +242,32 @@ jobs:
|
||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||
cat ./cypress.config.ts
|
||||
# Start frontend and run cypress
|
||||
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"
|
||||
#
|
||||
# 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"
|
||||
|
||||
- name: Zip Cypress videos
|
||||
if: always()
|
||||
- name: Zip Cypress failure evidence
|
||||
# Only on a failure: a green run has nothing to look at.
|
||||
if: failure()
|
||||
run: |
|
||||
mkdir -p ./client/cypress/videos
|
||||
zip -r cypress-videos ./client/cypress/videos
|
||||
mkdir -p ./client/cypress/screenshots ./client/cypress/videos
|
||||
zip -r cypress-artifacts.zip ./client/cypress/screenshots ./client/cypress/videos
|
||||
|
||||
- name: Add cypress videos artifacts
|
||||
if: always()
|
||||
- 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.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: cypress-videos.zip
|
||||
path: cypress-videos.zip
|
||||
name: cypress-artifacts.zip
|
||||
path: cypress-artifacts.zip
|
||||
|
||||
- name: Remove registry config
|
||||
# written earlier in this job; nothing deletes it on the way out
|
||||
|
||||
@@ -132,11 +132,13 @@ jobs:
|
||||
if: ${{ startsWith(gitea.ref, 'refs/tags/') }}
|
||||
run: |
|
||||
cd sas/mocks/sasjs
|
||||
# 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
|
||||
# the payload asks for OUTFMT=JSON, so a failed seed fails this step
|
||||
# rather than being published as an empty site (the drive is checked
|
||||
# as well, before the snapshot is taken)
|
||||
../../node_modules/.bin/sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json
|
||||
# the sample tables the demo site opens with are a separate service
|
||||
# now, so that makedata matches what a SAS deployment builds
|
||||
../../node_modules/.bin/sasjs request services/admin/createdemodata -t server-ci -o ./createdemodata_out.json
|
||||
|
||||
DATA_DIR="../../../sasjs_root/drive/files$APP_LOC/data"
|
||||
SEEDED=$(find "$DATA_DIR" -name '*.json' 2>/dev/null | wc -l)
|
||||
|
||||
+106
@@ -1,3 +1,109 @@
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
+11
-2
@@ -131,6 +131,13 @@ 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)
|
||||
@@ -147,9 +154,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`). 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 or HTML and set their own content headers (`admin/exportdb`, `admin/makedata`, `public/getrawdata`, `public/getddl`), and the lineage captures. 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`). `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.
|
||||
|
||||
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.
|
||||
`admin/makedata` builds only what the SAS service builds: the control library defined by `mpe_makedatamodel.sas`, seeded with the rows `mpe_makedata.sas` inserts. `sas/mocks/test/makedata-parity.test.js` reads both macros and fails when the mock estate drifts from them, so the mock and a deployed site open with the same tables and the same configuration.
|
||||
|
||||
The fixtures the Cypress suite needs but a DC install does not have live in `admin/createdemodata`, which runs after makedata: the `TESTDATA` demo library (`MPE_X_NEW`, `MPE_X_FORMULA_TEST`, `DERIVED_DEMO`, etc.) with its validation rules (HARDREGEX, SOFTREGEX, HARDSELECT, READONLY, HIDDEN, ROUND, NUMBER_FORMAT) and dropdown values, the `MPE_X_SEARCH` and `MPE_X_WIDE` viewer fixtures, and the `DC996664` multi-load library. It is safe to run twice, and refuses to run before makedata.
|
||||
|
||||
There is also a SAS 9 mock variant under `sas/mocks/sas9/` (target `sas9-mocks`) that mimics SAS 9 stored processes with the same JS approach but a different appLoc (`/User Folders/sasdemo/`). Its services are fixed recordings of the SAS 9 responses, emitted through `webout()` in the same way; the payloads are the SAS 9 ones (metadata URIs, `BUILTIN\Administrators` owners, `D:\...` paths, with their separators - the front end splits on `\`, so a value that arrives without it exercises the wrong branch). Only the payloads are SAS 9 flavour: the envelope around them (`_PROGRAM`, `SYSUSERID`, `SYSVLONG`, `SYSDATE`/`SYSTIME`) is whatever runtime serves the request, so do not read this variant for SAS 9 envelope values.
|
||||
|
||||
|
||||
@@ -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/makedata.js). It is deterministic, so every
|
||||
// (sas/mocks/sasjs/services/admin/createdemodata.js). It is deterministic, so every
|
||||
// count below can be derived by hand:
|
||||
// - 1000 rows, PK 1001-2000, columns in demo order: PRIMARY_KEY_FIELD,
|
||||
// SITE_NAME, VESSEL, SAMPLE_COUNT, DEPTH_M, SPECIES, CRUISE_DATE,
|
||||
|
||||
@@ -45,20 +45,10 @@ context('licensing tests: ', function () {
|
||||
generateKeys(keyData, (keysGen: any) => {
|
||||
keys = keysGen
|
||||
|
||||
cy.wait(2000)
|
||||
|
||||
isLicensingPage((result: boolean) => {
|
||||
if (result) {
|
||||
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
|
||||
|
||||
cy.wait(2000)
|
||||
}
|
||||
|
||||
gotoLicensingUpdate()
|
||||
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
|
||||
cy.then(() => {
|
||||
acceptTermsIfPresented((result: boolean) => {
|
||||
if (result) {
|
||||
cy.wait(10000)
|
||||
}
|
||||
|
||||
visitPage('home')
|
||||
|
||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||
@@ -86,18 +76,9 @@ 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()
|
||||
})
|
||||
@@ -120,19 +101,9 @@ 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) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
}
|
||||
verifyLicensingPage(
|
||||
'Licence key is expired - please contact',
|
||||
(success: boolean) => {
|
||||
@@ -166,19 +137,9 @@ 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) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
}
|
||||
verifyLicensingPage(
|
||||
'Licence key is invalid - please contact',
|
||||
(success: boolean) => {
|
||||
@@ -212,19 +173,9 @@ 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) => {
|
||||
if (result) {
|
||||
cy.wait(20000)
|
||||
}
|
||||
verifyLicensingPage(
|
||||
'SYSSITE (below) is not found',
|
||||
(success: boolean) => {
|
||||
@@ -247,7 +198,9 @@ 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.get('button').contains('Paste licence').click()
|
||||
cy.contains('button', 'Paste licence', {
|
||||
timeout: longerCommandTimeout
|
||||
}).click()
|
||||
|
||||
// Defaults to the combined single-field form - switch to the legacy
|
||||
// two-field layout, since this test targets that path specifically.
|
||||
@@ -287,25 +240,17 @@ 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.
|
||||
isLicensingPage((result: boolean) => {
|
||||
cy.then(() => {
|
||||
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', {
|
||||
@@ -317,10 +262,7 @@ context('licensing tests: ', function () {
|
||||
}
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000)
|
||||
}
|
||||
gotoLicensingUpdate()
|
||||
|
||||
// proceed() generates the combined key asynchronously, then
|
||||
// enqueues the actual page commands only once its promise
|
||||
@@ -344,15 +286,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.
|
||||
isLicensingPage((result: boolean) => {
|
||||
cy.then(() => {
|
||||
const proceed = () =>
|
||||
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
|
||||
(combinedKey) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
cy.contains('button', 'Paste licence', {
|
||||
timeout: longerCommandTimeout
|
||||
}).click()
|
||||
cy.get('button')
|
||||
.contains('Paste as two separate keys instead')
|
||||
.click()
|
||||
@@ -375,13 +317,7 @@ 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', {
|
||||
@@ -395,10 +331,7 @@ 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.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000)
|
||||
}
|
||||
gotoLicensingUpdate()
|
||||
cy.then(() => proceed())
|
||||
})
|
||||
})
|
||||
@@ -414,11 +347,9 @@ 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.
|
||||
isLicensingPage((result: boolean) => {
|
||||
cy.then(() => {
|
||||
const proceed = () =>
|
||||
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
|
||||
(combinedKey) => {
|
||||
@@ -432,13 +363,7 @@ 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', {
|
||||
@@ -452,10 +377,7 @@ 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.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000)
|
||||
}
|
||||
gotoLicensingUpdate()
|
||||
cy.then(() => proceed())
|
||||
})
|
||||
})
|
||||
@@ -476,15 +398,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.
|
||||
isLicensingPage((result: boolean) => {
|
||||
cy.then(() => {
|
||||
const proceed = () =>
|
||||
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
|
||||
(combinedKey) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
cy.contains('button', 'Paste licence', {
|
||||
timeout: longerCommandTimeout
|
||||
}).click()
|
||||
|
||||
cy.get('.combined-key-form textarea', {
|
||||
timeout: longerCommandTimeout
|
||||
@@ -522,12 +444,8 @@ 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.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
gotoLicensingUpdate()
|
||||
cy.then(() => proceed())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -550,22 +468,10 @@ 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`,
|
||||
@@ -583,8 +489,6 @@ context('licensing tests: ', function () {
|
||||
|
||||
visitPage('home')
|
||||
|
||||
cy.wait(2000)
|
||||
|
||||
verifyLicensingPage(
|
||||
'The registered number of users reached the limit specified for your licence.',
|
||||
(success: boolean) => {
|
||||
@@ -620,17 +524,9 @@ 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}`,
|
||||
@@ -641,7 +537,6 @@ 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.',
|
||||
() => {
|
||||
@@ -660,6 +555,27 @@ context('licensing tests: ', function () {
|
||||
}
|
||||
})
|
||||
|
||||
// A key change makes the app re-bootstrap its licence state, rendering
|
||||
// .app-loading and then one of these states. Waiting for it to settle replaces
|
||||
// the 2s/10s/20s sleeps that used to stand in for it, and costs nothing when
|
||||
// the app is already there.
|
||||
const waitForAppSettled = () => {
|
||||
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
||||
cy.get('.nav-tree, #demo-banner, .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.
|
||||
const gotoLicensingUpdate = () => {
|
||||
waitForAppSettled()
|
||||
cy.location('hash').then((hash: string) => {
|
||||
if (!hash.includes('licensing/update')) visitPage('licensing/update')
|
||||
})
|
||||
}
|
||||
|
||||
const logout = (callback?: any) => {
|
||||
cy.get('.header-actions .dropdown-toggle')
|
||||
.click()
|
||||
@@ -681,6 +597,9 @@ const acceptTermsIfPresented = (callback?: any) => {
|
||||
.then(() => {
|
||||
cy.get('#checkbox1')
|
||||
.click()
|
||||
.then(() => {
|
||||
waitForAppSettled()
|
||||
})
|
||||
.then(() => {
|
||||
if (callback) callback(true)
|
||||
})
|
||||
@@ -700,25 +619,26 @@ const isLicensingPage = (callback: any) => {
|
||||
}
|
||||
|
||||
const verifyLicensingPage = (text: string, callback: any) => {
|
||||
// visitPage('home')
|
||||
// The app redirects to the licensing page when the key is bad; waiting for
|
||||
// that URL is what the sleep-then-check-once version was guessing at, and a
|
||||
// redirect that never comes fails here instead of hanging the test.
|
||||
cy.location('hash', { timeout: longerCommandTimeout }).should(
|
||||
'match',
|
||||
/licensing\/(?!register)/
|
||||
)
|
||||
|
||||
cy.wait(1000)
|
||||
isLicensingPage((result: boolean) => {
|
||||
if (result) {
|
||||
cy.get('.key-error')
|
||||
.should('contain', text)
|
||||
.then((treeNodes: any) => {
|
||||
callback(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
cy.get('.key-error', { timeout: longerCommandTimeout })
|
||||
.should('contain', text)
|
||||
.then(() => {
|
||||
callback(true)
|
||||
})
|
||||
}
|
||||
|
||||
const verifyLicensingWarning = (text: string, callback: any) => {
|
||||
visitPage('home')
|
||||
|
||||
cy.wait(1000)
|
||||
cy.get("div[role='alert'] .alert-text")
|
||||
waitForAppSettled()
|
||||
cy.get("div[role='alert'] .alert-text", { timeout: longerCommandTimeout })
|
||||
.invoke('text')
|
||||
.should('contain', text)
|
||||
.then(() => {
|
||||
@@ -727,7 +647,9 @@ const verifyLicensingWarning = (text: string, callback: any) => {
|
||||
}
|
||||
|
||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
cy.contains('button', 'Paste licence', {
|
||||
timeout: longerCommandTimeout
|
||||
}).click()
|
||||
|
||||
// Defaults to the combined single-field form - this helper exercises the
|
||||
// two-part path specifically, so switch to it first.
|
||||
@@ -742,10 +664,13 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||
.trigger('input')
|
||||
.should('not.be.undefined')
|
||||
cy.get('button.apply-keys').click()
|
||||
waitForAppSettled()
|
||||
}
|
||||
|
||||
const inputCombinedKeyPage = (combinedKey: string) => {
|
||||
cy.get('button').contains('Paste licence').click()
|
||||
cy.contains('button', 'Paste licence', {
|
||||
timeout: longerCommandTimeout
|
||||
}).click()
|
||||
|
||||
// Combined is the default paste format - no toggle click needed here,
|
||||
// unlike inputLicenseKeyPage's legacy two-field path.
|
||||
@@ -815,7 +740,6 @@ const updateUsersTable = (options: any, callback?: any) => {
|
||||
// .type('{enter}')
|
||||
// })
|
||||
submitTable(() => {
|
||||
cy.wait(2000)
|
||||
approveTable(callback)
|
||||
})
|
||||
})
|
||||
@@ -838,7 +762,6 @@ const changeLicenseKeyTable = (keys: any, callback?: any) => {
|
||||
],
|
||||
() => {
|
||||
submitTable(() => {
|
||||
cy.wait(2000)
|
||||
approveTable(() => {
|
||||
cy.reload()
|
||||
if (callback) callback()
|
||||
@@ -853,7 +776,6 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
|
||||
isLicensingPage((result: boolean) => {
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000)
|
||||
}
|
||||
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
|
||||
|
||||
|
||||
@@ -793,11 +793,47 @@ 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) => {
|
||||
cy.contains('.open-viewbox', viewbox_tablename, {
|
||||
matchCase: false,
|
||||
timeout: longerCommandTimeout
|
||||
}).click()
|
||||
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 openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||
@@ -941,47 +977,90 @@ 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[]
|
||||
) => {
|
||||
cy.get('.add-new clr-tree > clr-tree-node', {
|
||||
timeout: longerCommandTimeout
|
||||
}).then((treeNodes: any) => {
|
||||
let viyaLib
|
||||
// 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'
|
||||
)
|
||||
|
||||
for (let node of treeNodes) {
|
||||
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||
viyaLib = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
const libraryNode = () =>
|
||||
cy.contains('.add-new clr-tree > clr-tree-node', libraryName, {
|
||||
timeout: longerCommandTimeout
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const pause = (ms: number) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
resolve(null)
|
||||
}, ms)
|
||||
})
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
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 visitPage = (url: string) => {
|
||||
|
||||
+17
-17
@@ -11,23 +11,6 @@
|
||||
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 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">
|
||||
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
|
||||
@@ -45,6 +28,23 @@
|
||||
</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:
|
||||
|
||||
+81
@@ -1,4 +1,10 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
@@ -148,3 +154,78 @@ describe('SasjsConfiguratorComponent directory default', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,10 @@ 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 } from '../shared/hot-export/hot-export.util'
|
||||
import {
|
||||
buildExportMenuItem,
|
||||
EXPORT_MENU_KEY
|
||||
} from '../shared/hot-export/hot-export.util'
|
||||
import {
|
||||
$DataFormats,
|
||||
DSMeta,
|
||||
@@ -421,7 +424,10 @@ export class EditorComponent
|
||||
},
|
||||
// Client-side export of the current grid view (or selection).
|
||||
// skipLeadingCols=1 drops the `Delete?` housekeeping column (col 0).
|
||||
export_file: buildExportMenuItem(() => this.libds || 'export', 1),
|
||||
[EXPORT_MENU_KEY]: buildExportMenuItem(
|
||||
() => this.libds || 'export',
|
||||
1
|
||||
),
|
||||
sp1: {
|
||||
name: '---------',
|
||||
hidden: () => this.hotTable.readOnly === true
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
exportGrid,
|
||||
isRangeEmpty,
|
||||
getExportRange,
|
||||
buildExportMenuItem
|
||||
buildExportMenuItem,
|
||||
EXPORT_MENU_KEY
|
||||
} from './hot-export.util'
|
||||
|
||||
/**
|
||||
@@ -203,9 +204,9 @@ describe('hot-export grid extraction', () => {
|
||||
const names = item.submenu.items.map((sub: any) => sub.name)
|
||||
|
||||
expect(keys).toEqual([
|
||||
'export_file:csv',
|
||||
'export_file:xlsx',
|
||||
'export_file:pdf'
|
||||
`${EXPORT_MENU_KEY}:csv`,
|
||||
`${EXPORT_MENU_KEY}:xlsx`,
|
||||
`${EXPORT_MENU_KEY}:pdf`
|
||||
])
|
||||
expect(names).toContain('PDF (.pdf)')
|
||||
})
|
||||
|
||||
@@ -311,9 +311,22 @@ export async function exportGrid(
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a reusable `export_file` context-menu item (CSV / Excel / PDF submenu) that
|
||||
* 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
|
||||
* exports the given grid via {@link exportGrid}. Add it to any HOT context-menu
|
||||
* `items` map under the `export_file` key.
|
||||
* `items` map under {@link EXPORT_MENU_KEY}.
|
||||
*
|
||||
* @param getFilename Returns the export file name at click time.
|
||||
* @param skipLeadingCols Leading UI columns to exclude (see {@link exportGrid}).
|
||||
@@ -332,28 +345,23 @@ 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 exportFile plugin uses at runtime; cast to
|
||||
// satisfy the (stricter) MenuConfig typing.
|
||||
// form is what HOT's own menu uses at runtime; cast to satisfy the
|
||||
// (stricter) MenuConfig typing.
|
||||
items: [
|
||||
{
|
||||
key: 'export_file:csv',
|
||||
key: `${EXPORT_MENU_KEY}:csv`,
|
||||
name: 'CSV',
|
||||
callback: makeCb('csv')
|
||||
},
|
||||
{
|
||||
key: 'export_file:xlsx',
|
||||
key: `${EXPORT_MENU_KEY}:xlsx`,
|
||||
name: 'Excel (.xlsx)',
|
||||
callback: makeCb('xlsx')
|
||||
},
|
||||
{
|
||||
key: 'export_file:pdf',
|
||||
key: `${EXPORT_MENU_KEY}:pdf`,
|
||||
name: 'PDF (.pdf)',
|
||||
callback: makeCb('pdf')
|
||||
}
|
||||
|
||||
@@ -58,7 +58,13 @@
|
||||
Errors ({{ programLog.errors!.length }})
|
||||
@for (issue of programLog.errors; track $index) {
|
||||
<clr-tree-node
|
||||
(click)="goToLogLine(issue, 'request_' + i)"
|
||||
(click)="
|
||||
goToLogLine(
|
||||
issue,
|
||||
programLog,
|
||||
'request_' + i
|
||||
)
|
||||
"
|
||||
>
|
||||
<button class="clr-treenode-link">
|
||||
@if (issue.code) {
|
||||
@@ -84,7 +90,13 @@
|
||||
track $index
|
||||
) {
|
||||
<clr-tree-node
|
||||
(click)="goToLogLine(issue, 'request_' + i)"
|
||||
(click)="
|
||||
goToLogLine(
|
||||
issue,
|
||||
programLog,
|
||||
'request_' + i
|
||||
)
|
||||
"
|
||||
>
|
||||
<button class="clr-treenode-link">
|
||||
@if (issue.code) {
|
||||
@@ -104,7 +116,13 @@
|
||||
Notes ({{ programLog.notes!.length }})
|
||||
@for (issue of programLog.notes; track $index) {
|
||||
<clr-tree-node
|
||||
(click)="goToLogLine(issue, 'request_' + i)"
|
||||
(click)="
|
||||
goToLogLine(
|
||||
issue,
|
||||
programLog,
|
||||
'request_' + i
|
||||
)
|
||||
"
|
||||
>
|
||||
<button class="clr-treenode-link">
|
||||
@if (issue.code) {
|
||||
@@ -119,17 +137,41 @@
|
||||
</clr-tree-node>
|
||||
}
|
||||
</clr-tree>
|
||||
@if (serverType !== 'SASVIYA') {
|
||||
<div
|
||||
class="log-wrapper saslog"
|
||||
[innerHTML]="programLog.logHtml"
|
||||
></div>
|
||||
}
|
||||
@if (serverType === 'SASVIYA') {
|
||||
<div
|
||||
class="log-wrapper saslog viya"
|
||||
[textContent]="programLog.logFile"
|
||||
></div>
|
||||
@if (programLog.logWindow; as logWindow) {
|
||||
@if (logWindow.visible.length) {
|
||||
<div class="log-wrapper saslog">
|
||||
@for (line of logWindow.visible; track $index) {
|
||||
<div class="log-line">
|
||||
<span class="log-line-number">{{
|
||||
$index + 1
|
||||
}}</span>
|
||||
@if (programLog.logIsHtml) {
|
||||
<span
|
||||
class="log-line-text"
|
||||
[innerHTML]="line"
|
||||
></span>
|
||||
} @else {
|
||||
<span class="log-line-text">{{
|
||||
line
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (hasMoreLines(logWindow)) {
|
||||
<p class="log-more">
|
||||
Showing {{ logWindow.visible.length }} of
|
||||
{{ logWindow.all.length }} lines
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
(click)="showMoreLines(logWindow)"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (programLog.logFile.length > 0) {
|
||||
<button
|
||||
@@ -147,9 +189,31 @@
|
||||
<button clrTabLink>SAS Source Code</button>
|
||||
<ng-template [(clrIfActive)]="sasSourceCodeActive">
|
||||
<clr-tab-content>
|
||||
<div class="log-wrapper">
|
||||
{{ programLog.sourceCode }}
|
||||
</div>
|
||||
@if (programLog.sourceWindow; as sourceWindow) {
|
||||
<div class="log-wrapper">
|
||||
@for (line of sourceWindow.visible; track $index) {
|
||||
<div class="log-line">
|
||||
<span class="log-line-number">{{
|
||||
$index + 1
|
||||
}}</span>
|
||||
<span class="log-line-text">{{ line }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (hasMoreLines(sourceWindow)) {
|
||||
<p class="log-more">
|
||||
Showing {{ sourceWindow.visible.length }} of
|
||||
{{ sourceWindow.all.length }} lines
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
(click)="showMoreLines(sourceWindow)"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@if (programLog.sourceCode.length > 0) {
|
||||
<button
|
||||
(click)="downloadSourceCode(programLog.sourceCode)"
|
||||
@@ -166,9 +230,34 @@
|
||||
<button clrTabLink>SAS Generated Code</button>
|
||||
<ng-template [(clrIfActive)]="sasGeneratedCodeActive">
|
||||
<clr-tab-content>
|
||||
<div class="log-wrapper">
|
||||
{{ programLog.generatedCode }}
|
||||
</div>
|
||||
@if (programLog.generatedWindow; as generatedWindow) {
|
||||
<div class="log-wrapper">
|
||||
@for (
|
||||
line of generatedWindow.visible;
|
||||
track $index
|
||||
) {
|
||||
<div class="log-line">
|
||||
<span class="log-line-number">{{
|
||||
$index + 1
|
||||
}}</span>
|
||||
<span class="log-line-text">{{ line }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (hasMoreLines(generatedWindow)) {
|
||||
<p class="log-more">
|
||||
Showing {{ generatedWindow.visible.length }} of
|
||||
{{ generatedWindow.all.length }} lines
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
(click)="showMoreLines(generatedWindow)"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@if (programLog.generatedCode.length > 0) {
|
||||
<button
|
||||
(click)="
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { LogIssue } from '@sasjs/utils/logs'
|
||||
import { SasLogsModalComponent } from './sas-logs-modal.component'
|
||||
import { LogIssue, parseLog } from '@sasjs/utils/logs'
|
||||
import {
|
||||
FIRST_RENDERED_LINES,
|
||||
MORE_RENDERED_LINES,
|
||||
SasLogsModalComponent
|
||||
} from './sas-logs-modal.component'
|
||||
|
||||
const LOG = [
|
||||
'1 data want;',
|
||||
@@ -35,7 +39,8 @@ describe('SasLogsModalComponent', () => {
|
||||
|
||||
const buildComponent = (
|
||||
requests: any[] = [],
|
||||
serverType: string = 'SAS9'
|
||||
serverType: string = 'SAS9',
|
||||
changeDetector: any = { detectChanges: () => {} }
|
||||
): SasLogsModalComponent => {
|
||||
sasService = {
|
||||
getSasjsConfig: () => ({ appLoc: '/Public/app' }),
|
||||
@@ -50,7 +55,8 @@ describe('SasLogsModalComponent', () => {
|
||||
return new SasLogsModalComponent(
|
||||
sasService,
|
||||
{ log: () => {} } as any,
|
||||
helperService
|
||||
helperService,
|
||||
changeDetector
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,16 +113,65 @@ describe('SasLogsModalComponent', () => {
|
||||
|
||||
expect(request.logIssues).toEqual([])
|
||||
expect(request.errors).toEqual([])
|
||||
expect(request.logHtml).toEqual('')
|
||||
expect(request.logWindow).toEqual({ all: [], visible: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitLines', () => {
|
||||
it('splits the log, the source and the generated code into lines', () => {
|
||||
const request: any = {
|
||||
logFile: LOG,
|
||||
sourceCode: 'data want;\nrun;\n',
|
||||
generatedCode: 'proc print;\nrun;'
|
||||
}
|
||||
component.analyseLog(request)
|
||||
|
||||
expect(request.logWindow.all).toHaveSize(10)
|
||||
expect(request.logWindow.all[5]).toContain('WARNING: Division by zero')
|
||||
expect(request.sourceWindow.all).toEqual(['data want;', 'run;'])
|
||||
expect(request.generatedWindow.all).toEqual(['proc print;', 'run;'])
|
||||
})
|
||||
|
||||
it('does not count the newline that ends the text as a line', () => {
|
||||
expect(component.splitLines('a;\nb;\n')).toEqual(['a;', 'b;'])
|
||||
expect(component.splitLines('a;\nb;')).toEqual(['a;', 'b;'])
|
||||
})
|
||||
|
||||
it('keeps a carriage return out of the line it ends', () => {
|
||||
expect(component.splitLines('NOTE: a.\r\nNOTE: b.\r\n')).toEqual([
|
||||
'NOTE: a.',
|
||||
'NOTE: b.'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports no lines when there is nothing to show', () => {
|
||||
expect(component.splitLines('')).toEqual([])
|
||||
expect(component.splitLines(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('renders a Viya log as text rather than markup', () => {
|
||||
component = buildComponent([], 'SASVIYA')
|
||||
const request: any = { logFile: LOG }
|
||||
component.analyseLog(request)
|
||||
|
||||
expect(request.logIsHtml).toBeFalse()
|
||||
expect(request.logWindow.all[5]).toContain('WARNING: Division by zero')
|
||||
expect(request.logWindow.all[5]).not.toContain('<font')
|
||||
})
|
||||
|
||||
it('renders a SAS 9 log with its error and warning lines marked', () => {
|
||||
const request: any = { logFile: LOG }
|
||||
component.analyseLog(request)
|
||||
|
||||
expect(request.logIsHtml).toBeTrue()
|
||||
expect(request.logWindow.all[5]).toContain('<font class="warning-line">')
|
||||
expect(request.logWindow.all[6]).toContain('<font class="error-line">')
|
||||
})
|
||||
})
|
||||
|
||||
describe('markIssueLines', () => {
|
||||
it('marks every line an issue occupies, the wrapped ones included', () => {
|
||||
const request: any = { logFile: LOG }
|
||||
component.analyseLog(request)
|
||||
|
||||
const lines: string[] = request.logHtml.split('\n')
|
||||
const lines = component.markIssueLines(LOG, parseLog(LOG)).split('\n')
|
||||
|
||||
expect(lines[5]).toEqual(
|
||||
'<font class="warning-line">WARNING: Division by zero detected at line 3 column 11.</font>'
|
||||
@@ -129,10 +184,7 @@ describe('SasLogsModalComponent', () => {
|
||||
})
|
||||
|
||||
it('leaves a notable note unmarked, and the other lines untouched', () => {
|
||||
const request: any = { logFile: LOG }
|
||||
component.analyseLog(request)
|
||||
|
||||
const lines: string[] = request.logHtml.split('\n')
|
||||
const lines = component.markIssueLines(LOG, parseLog(LOG)).split('\n')
|
||||
|
||||
expect(lines[9]).toEqual('NOTE: Invalid data for age in line 42 3-5.')
|
||||
expect(lines[0]).toEqual('1 data want;')
|
||||
@@ -146,72 +198,208 @@ describe('SasLogsModalComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the rendered window', () => {
|
||||
const block = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `line ${index + 1}`).join(
|
||||
'\n'
|
||||
)
|
||||
|
||||
it('renders the first block of a long pane and keeps the rest back', () => {
|
||||
const pane = component.lineWindow(block(5000))
|
||||
|
||||
expect(pane.all).toHaveSize(5000)
|
||||
expect(pane.visible).toHaveSize(FIRST_RENDERED_LINES)
|
||||
expect(component.hasMoreLines(pane)).toBeTrue()
|
||||
})
|
||||
|
||||
it('renders everything when the pane is shorter than one block', () => {
|
||||
const pane = component.lineWindow(block(12))
|
||||
|
||||
expect(pane.visible).toHaveSize(12)
|
||||
expect(component.hasMoreLines(pane)).toBeFalse()
|
||||
})
|
||||
|
||||
it('adds one block per Show more and stops at the last line', () => {
|
||||
const pane = component.lineWindow(block(5000))
|
||||
|
||||
component.showMoreLines(pane)
|
||||
|
||||
expect(pane.visible).toHaveSize(
|
||||
FIRST_RENDERED_LINES + MORE_RENDERED_LINES
|
||||
)
|
||||
|
||||
while (component.hasMoreLines(pane)) component.showMoreLines(pane)
|
||||
|
||||
expect(pane.visible).toHaveSize(5000)
|
||||
expect(pane.visible[4999]).toEqual('line 5000')
|
||||
})
|
||||
|
||||
it('renders through a line a link points at, once', () => {
|
||||
const pane = component.lineWindow(block(5000))
|
||||
|
||||
expect(component.renderThrough(pane, 10)).toBeFalse()
|
||||
expect(component.renderThrough(pane, 1200)).toBeTrue()
|
||||
expect(pane.visible).toHaveSize(1201)
|
||||
expect(component.renderThrough(pane, 1200)).toBeFalse()
|
||||
|
||||
// A line past the end renders the rest rather than nothing.
|
||||
expect(component.renderThrough(pane, 9000)).toBeTrue()
|
||||
expect(pane.visible).toHaveSize(5000)
|
||||
expect(component.renderThrough(pane, 9000)).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
describe('goToLogLine', () => {
|
||||
let wrapper: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
/* The rendered log: one .log-line row per line, as the template draws it. */
|
||||
const renderLines = (count: number, requestId = 'request_0') => {
|
||||
const stackBlock = document.createElement('div')
|
||||
stackBlock.id = 'request_0'
|
||||
stackBlock.id = requestId
|
||||
wrapper = document.createElement('div')
|
||||
wrapper.className = 'log-wrapper saslog'
|
||||
wrapper.textContent = LOG
|
||||
wrapper.style.height = '40px'
|
||||
wrapper.style.overflow = 'auto'
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
const row = document.createElement('div')
|
||||
row.className = 'log-line'
|
||||
|
||||
const number = document.createElement('span')
|
||||
number.className = 'log-line-number'
|
||||
number.textContent = `${index + 1}`
|
||||
|
||||
const text = document.createElement('span')
|
||||
text.className = 'log-line-text'
|
||||
text.textContent = `line ${index + 1}`
|
||||
|
||||
row.appendChild(number)
|
||||
row.appendChild(text)
|
||||
wrapper.appendChild(row)
|
||||
}
|
||||
|
||||
stackBlock.appendChild(wrapper)
|
||||
document.body.appendChild(stackBlock)
|
||||
|
||||
// The height of a rendered line needs a layout; a fixed value keeps the
|
||||
// arithmetic under test deterministic.
|
||||
;(component as any).measureLineHeight = () => 10
|
||||
return stackBlock
|
||||
}
|
||||
|
||||
afterEach(() => wrapper?.parentElement?.remove())
|
||||
|
||||
const row = (index: number) => wrapper.querySelectorAll('.log-line')[index]
|
||||
|
||||
it('scrolls the line the parser reported to the top of the log', () => {
|
||||
renderLines(60)
|
||||
const target = row(40) as HTMLElement
|
||||
const lineTop =
|
||||
target.getBoundingClientRect().top - wrapper.getBoundingClientRect().top
|
||||
|
||||
component.goToLogLine(issue({ line: 40 }), undefined, 'request_0')
|
||||
|
||||
expect(
|
||||
Math.abs(wrapper.scrollTop - (lineTop - wrapper.clientTop))
|
||||
).toBeLessThan(1.5)
|
||||
expect(lineTop).toBeGreaterThan(wrapper.clientHeight)
|
||||
})
|
||||
|
||||
afterEach(() => wrapper.parentElement?.remove())
|
||||
it('keeps the border out of the scroll offset', () => {
|
||||
renderLines(60)
|
||||
wrapper.style.border = '6px solid #c9c9c9'
|
||||
const target = row(40) as HTMLElement
|
||||
const lineTop =
|
||||
target.getBoundingClientRect().top - wrapper.getBoundingClientRect().top
|
||||
|
||||
it('positions the highlight from the line the parser reported', () => {
|
||||
component.goToLogLine(issue({ line: 6 }), 'request_0')
|
||||
component.goToLogLine(issue({ line: 40 }), undefined, 'request_0')
|
||||
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--line-top')
|
||||
).toEqual('60px')
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--line-height')
|
||||
).toEqual('10px')
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--line-count')
|
||||
).toEqual('1')
|
||||
expect(wrapper.clientTop).toEqual(6)
|
||||
expect(Math.abs(wrapper.scrollTop - (lineTop - 6))).toBeLessThan(1.5)
|
||||
})
|
||||
|
||||
it('does not jump to the first of two identical messages', () => {
|
||||
const repeated = issue({ line: 7, message: 'a message' })
|
||||
it('treats an issue that ends before it starts as one line', () => {
|
||||
renderLines(60)
|
||||
|
||||
component.goToLogLine(repeated, 'request_0')
|
||||
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--line-top')
|
||||
).toEqual('70px')
|
||||
})
|
||||
|
||||
it('covers every line a wrapped message runs to', () => {
|
||||
component.goToLogLine(issue({ line: 7, endLine: 8 }), 'request_0')
|
||||
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--line-count')
|
||||
).toEqual('2')
|
||||
})
|
||||
|
||||
it('flashes the colour of the severity it was given', () => {
|
||||
component.goToLogLine(
|
||||
issue({ line: 5, severity: 'warning' }),
|
||||
issue({ line: 40, endLine: 0 }),
|
||||
undefined,
|
||||
'request_0'
|
||||
)
|
||||
|
||||
const overlay = wrapper.querySelector('.line-highlight-overlay')
|
||||
expect(row(40).classList).toContain('error-highlight')
|
||||
expect(row(41).classList).not.toContain('error-highlight')
|
||||
})
|
||||
|
||||
expect(overlay).not.toBeNull()
|
||||
expect(overlay!.classList).toContain('warning-highlight')
|
||||
it('does not jump to the first of two identical messages', () => {
|
||||
renderLines(60)
|
||||
const lineTop =
|
||||
(row(41) as HTMLElement).getBoundingClientRect().top -
|
||||
wrapper.getBoundingClientRect().top
|
||||
|
||||
component.goToLogLine(
|
||||
issue({ line: 41, message: 'a message' }),
|
||||
undefined,
|
||||
'request_0'
|
||||
)
|
||||
|
||||
expect(Math.abs(wrapper.scrollTop - lineTop)).toBeLessThan(1.5)
|
||||
})
|
||||
|
||||
it('flashes the colour of the severity it was given', () => {
|
||||
renderLines(60)
|
||||
|
||||
component.goToLogLine(
|
||||
issue({ line: 5, severity: 'warning' }),
|
||||
undefined,
|
||||
'request_0'
|
||||
)
|
||||
|
||||
expect(row(5).classList).toContain('warning-highlight')
|
||||
expect(row(4).classList).not.toContain('warning-highlight')
|
||||
})
|
||||
|
||||
it('flashes every line a wrapped message runs to', () => {
|
||||
renderLines(60)
|
||||
|
||||
component.goToLogLine(
|
||||
issue({ line: 40, endLine: 42 }),
|
||||
undefined,
|
||||
'request_0'
|
||||
)
|
||||
|
||||
expect(row(40).classList).toContain('error-highlight')
|
||||
expect(row(41).classList).toContain('error-highlight')
|
||||
expect(row(42).classList).toContain('error-highlight')
|
||||
expect(row(43).classList).not.toContain('error-highlight')
|
||||
})
|
||||
|
||||
it('does nothing when the line is not on screen', () => {
|
||||
renderLines(3)
|
||||
|
||||
expect(() =>
|
||||
component.goToLogLine(issue({ line: 90 }), undefined, 'request_0')
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders the lines below the window before scrolling to one of them', () => {
|
||||
const long = Array.from(
|
||||
{ length: 500 },
|
||||
(_, index) => `line ${index + 1}`
|
||||
).join('\n')
|
||||
const request: any = { logFile: long }
|
||||
const detectChanges = jasmine.createSpy('detectChanges')
|
||||
component = buildComponent([], 'SAS9', { detectChanges })
|
||||
component.analyseLog(request)
|
||||
|
||||
expect(request.logWindow.visible).toHaveSize(FIRST_RENDERED_LINES)
|
||||
|
||||
component.goToLogLine(issue({ line: 450 }), request, 'request_0')
|
||||
|
||||
expect(request.logWindow.visible).toHaveSize(451)
|
||||
expect(detectChanges).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when the request has no log on screen', () => {
|
||||
expect(() =>
|
||||
component.goToLogLine(issue({ line: 1 }), 'request_9')
|
||||
component.goToLogLine(issue({ line: 1 }), undefined, 'request_9')
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
@@ -21,11 +22,32 @@ interface SASjsRequestExtended extends SASjsRequest {
|
||||
errors?: LogIssue[]
|
||||
warnings?: LogIssue[]
|
||||
notes?: LogIssue[]
|
||||
/** The log with its error and warning lines wrapped in markup, for rendering. */
|
||||
logHtml?: string
|
||||
/** The log, the source and the generated code, as rendered lines. */
|
||||
logWindow?: LineWindow
|
||||
sourceWindow?: LineWindow
|
||||
generatedWindow?: LineWindow
|
||||
/** Whether the log carries markup (SAS 9) or plain text (Viya, SASjs). */
|
||||
logIsHtml?: boolean
|
||||
selectedTable?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane's text as every line, and the prefix currently in the DOM. A row per
|
||||
* line costs Angular a binding and a sanitizer pass each - roughly 0.25ms a
|
||||
* line - so a log of tens of thousands of lines would freeze the modal for
|
||||
* seconds. Render a window and grow it on demand instead.
|
||||
*/
|
||||
export interface LineWindow {
|
||||
all: string[]
|
||||
visible: string[]
|
||||
}
|
||||
|
||||
/** Lines a pane renders when it first appears. */
|
||||
export const FIRST_RENDERED_LINES = 400
|
||||
|
||||
/** Lines each "Show more" adds, and each jump to a far line renders. */
|
||||
export const MORE_RENDERED_LINES = 2000
|
||||
|
||||
@Component({
|
||||
selector: 'app-sas-logs-modal',
|
||||
templateUrl: './sas-logs-modal.component.html',
|
||||
@@ -61,7 +83,8 @@ export class SasLogsModalComponent implements OnInit {
|
||||
constructor(
|
||||
private sasService: SasService,
|
||||
private loggerService: LoggerService,
|
||||
private helperService: HelperService
|
||||
private helperService: HelperService,
|
||||
private changeDetector: ChangeDetectorRef
|
||||
) {
|
||||
this.sasjsConfig = this.sasService.getSasjsConfig()
|
||||
this.serverType = this.sasService.getServerType()
|
||||
@@ -111,8 +134,9 @@ export class SasLogsModalComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Reads the request's log once and keeps what the template renders: the
|
||||
* issues SAS wrote, grouped by severity, and - for a log carrying markup -
|
||||
* the log itself with those lines marked.
|
||||
* issues SAS wrote, grouped by severity, the log itself with those lines
|
||||
* marked, and every pane as one entry per rendered line so that each line
|
||||
* carries a number and can be addressed without counting from the top.
|
||||
*/
|
||||
public analyseLog(request: SASjsRequestExtended) {
|
||||
const log = typeof request.logFile === 'string' ? request.logFile : ''
|
||||
@@ -122,7 +146,64 @@ export class SasLogsModalComponent implements OnInit {
|
||||
request.errors = issues.filter((issue) => issue.severity === 'error')
|
||||
request.warnings = issues.filter((issue) => issue.severity === 'warning')
|
||||
request.notes = issues.filter((issue) => issue.severity === 'note')
|
||||
request.logHtml = this.markIssueLines(log, issues)
|
||||
|
||||
const logIsHtml = this.serverType !== ServerType.SasViya
|
||||
|
||||
request.logIsHtml = logIsHtml
|
||||
request.logWindow = this.lineWindow(
|
||||
logIsHtml ? this.markIssueLines(log, issues) : log
|
||||
)
|
||||
request.sourceWindow = this.lineWindow(request.sourceCode)
|
||||
request.generatedWindow = this.lineWindow(request.generatedCode)
|
||||
}
|
||||
|
||||
/** A text block as every line, with the prefix the view starts with. */
|
||||
public lineWindow(text: string | undefined): LineWindow {
|
||||
const all = this.splitLines(text)
|
||||
|
||||
return { all, visible: all.slice(0, FIRST_RENDERED_LINES) }
|
||||
}
|
||||
|
||||
/** Whether a pane still holds lines it has not rendered. */
|
||||
public hasMoreLines(pane: LineWindow | undefined): boolean {
|
||||
return !!pane && pane.visible.length < pane.all.length
|
||||
}
|
||||
|
||||
/** Adds the next block of lines to what the pane renders. */
|
||||
public showMoreLines(pane: LineWindow) {
|
||||
this.renderThrough(pane, pane.visible.length + MORE_RENDERED_LINES - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders every line up to `line`, so a link to a line far down a long log
|
||||
* has a row to scroll to. Returns whether anything was added.
|
||||
*/
|
||||
public renderThrough(pane: LineWindow, line: number): boolean {
|
||||
if (line < pane.visible.length || pane.visible.length === pane.all.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
pane.visible.push(
|
||||
...pane.all.slice(
|
||||
pane.visible.length,
|
||||
Math.min(pane.all.length, line + 1)
|
||||
)
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** A text block as the lines the view renders, numbered from one. */
|
||||
public splitLines(text: string | undefined): string[] {
|
||||
if (!text) return []
|
||||
|
||||
const lines = text.split(/\r?\n/)
|
||||
|
||||
// The newline that ends the text does not open a line of its own; a blank
|
||||
// line in the middle of it does.
|
||||
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,58 +236,59 @@ export class SasLogsModalComponent implements OnInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the log to an issue and flashes its line, using the line number the
|
||||
* parser reported rather than searching the rendered text - a log repeats its
|
||||
* messages, so a search lands on the first of them every time.
|
||||
* Scrolls the log to an issue and flashes the lines it occupies, using the
|
||||
* rendered line element rather than a search of the text or arithmetic over
|
||||
* a measured line height: a log repeats its messages, so a search lands on
|
||||
* the first copy, and a line height rounded to whole pixels drifts further
|
||||
* from the line it means the longer the log is.
|
||||
*/
|
||||
public goToLogLine(issue: LogIssue, requestStackId: string) {
|
||||
public goToLogLine(
|
||||
issue: LogIssue,
|
||||
request: SASjsRequestExtended | undefined,
|
||||
requestStackId: string
|
||||
) {
|
||||
const endLine = Math.max(issue.endLine ?? issue.line, issue.line)
|
||||
|
||||
// A line past what the pane renders has no row to scroll to yet.
|
||||
if (request?.logWindow && this.renderThrough(request.logWindow, endLine)) {
|
||||
this.changeDetector.detectChanges()
|
||||
}
|
||||
|
||||
const logWrapper = document.querySelector(
|
||||
`#${requestStackId} .log-wrapper.saslog`
|
||||
) as HTMLElement | null
|
||||
|
||||
if (!logWrapper) return
|
||||
|
||||
const lineHeight = this.measureLineHeight(logWrapper)
|
||||
const lineCount = issue.endLine - issue.line + 1
|
||||
const top = lineHeight * issue.line
|
||||
const rows = Array.from(
|
||||
logWrapper.querySelectorAll<HTMLElement>('.log-line')
|
||||
).slice(issue.line, endLine + 1)
|
||||
|
||||
logWrapper.scrollTop = top
|
||||
const firstRow = rows[0]
|
||||
|
||||
// The height and position reach the stylesheet as CSS variables, which
|
||||
// keeps the inline style away from the element and therefore away from CSP.
|
||||
document.documentElement.style.setProperty(
|
||||
'--line-height',
|
||||
`${lineHeight}px`
|
||||
)
|
||||
document.documentElement.style.setProperty('--line-count', `${lineCount}`)
|
||||
document.documentElement.style.setProperty('--line-top', `${top}px`)
|
||||
if (!firstRow) return
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = `line-highlight-overlay ${issue.severity}-highlight`
|
||||
overlay.classList.add('temp-height-setter')
|
||||
overlay.classList.add('line-position-setter')
|
||||
// The border is outside the scrollable area, so it comes off the offset
|
||||
// rather than being scrolled past.
|
||||
logWrapper.scrollTop =
|
||||
firstRow.getBoundingClientRect().top -
|
||||
logWrapper.getBoundingClientRect().top -
|
||||
logWrapper.clientTop +
|
||||
logWrapper.scrollTop
|
||||
|
||||
logWrapper.appendChild(overlay)
|
||||
const flashClass = `${issue.severity}-highlight`
|
||||
|
||||
for (const row of rows) {
|
||||
row.classList.add(flashClass)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (logWrapper.contains(overlay)) {
|
||||
logWrapper.removeChild(overlay)
|
||||
for (const row of rows) {
|
||||
row.classList.remove(flashClass)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/** The height of one rendered log line, measured rather than assumed. */
|
||||
private measureLineHeight(logWrapper: HTMLElement): number {
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.className = 'temp-line-height-calc'
|
||||
tempDiv.textContent = 'X'
|
||||
logWrapper.appendChild(tempDiv)
|
||||
const lineHeight = tempDiv.clientHeight
|
||||
logWrapper.removeChild(tempDiv)
|
||||
|
||||
return lineHeight
|
||||
}
|
||||
|
||||
downloadLog(logFile: string) {
|
||||
const timestamp = new Date().valueOf()
|
||||
this.helperService.downloadTextFile(`logFile-${timestamp}`, logFile)
|
||||
|
||||
@@ -38,7 +38,10 @@ import { AutocompleteComponent } from 'src/app/shared/autocomplete/autocomplete.
|
||||
import { LibraryClickEmitter } from 'src/app/shared/dc-tree/models/LibraryClickEmitter'
|
||||
import { TableClickEmitter } from 'src/app/shared/dc-tree/models/TableClickEmitter'
|
||||
import { mergeColsRules } from 'src/app/shared/dc-validator/utils/mergeColsRules'
|
||||
import { buildExportMenuItem } from 'src/app/shared/hot-export/hot-export.util'
|
||||
import {
|
||||
buildExportMenuItem,
|
||||
EXPORT_MENU_KEY
|
||||
} from 'src/app/shared/hot-export/hot-export.util'
|
||||
import { globals, initFilter } from 'src/app/_globals'
|
||||
import { observeGridAria } from '../utils/grid-aria'
|
||||
import { ViewboxHotTable } from './models/viewbox-hot-table.model'
|
||||
@@ -553,7 +556,7 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
items: {
|
||||
copy_with_column_headers: {},
|
||||
copy_column_headers_only: {},
|
||||
export_file: buildExportMenuItem(() => 'viewbox')
|
||||
[EXPORT_MENU_KEY]: buildExportMenuItem(() => 'viewbox')
|
||||
}
|
||||
},
|
||||
multiColumnSorting: true,
|
||||
|
||||
@@ -27,7 +27,10 @@ import { QueryComponent } from '../query/query.component'
|
||||
|
||||
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
||||
import { HotTableInterface } from '../models/HotTable.interface'
|
||||
import { buildExportMenuItem } from '../shared/hot-export/hot-export.util'
|
||||
import {
|
||||
buildExportMenuItem,
|
||||
EXPORT_MENU_KEY
|
||||
} from '../shared/hot-export/hot-export.util'
|
||||
import { LoggerService } from '../services/logger.service'
|
||||
import Handsontable from 'handsontable'
|
||||
import {
|
||||
@@ -214,7 +217,9 @@ export class ViewerComponent
|
||||
items: {
|
||||
copy_with_column_headers: {},
|
||||
copy_column_headers_only: {},
|
||||
export_file: buildExportMenuItem(() => this.tableTitle || 'export'),
|
||||
[EXPORT_MENU_KEY]: buildExportMenuItem(
|
||||
() => this.tableTitle || 'export'
|
||||
),
|
||||
// Navigates rather than just flipping `useLabels` locally, so the URL
|
||||
// stays the single source of truth (shareable/refreshable state) —
|
||||
// the queryParams subscription in ngOnInit picks up the change and
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { VERSION } from './version'
|
||||
|
||||
/**
|
||||
* The generated build stamp is what the user dropdown, the System screen and
|
||||
* the browser_info diagnostics report. It is produced by `src/version.ts`
|
||||
* from git-describe, which only sees tags reachable from HEAD - a shallow
|
||||
* checkout has none, so the semantic part has to come from package.json.
|
||||
*/
|
||||
describe('generated VERSION', () => {
|
||||
it('carries a semantic version even when no tag is reachable', () => {
|
||||
expect(String(VERSION.tag)).toMatch(/^v\d+\.\d+\.\d+/)
|
||||
})
|
||||
|
||||
it('carries the commit hash alongside the semantic version', () => {
|
||||
expect(String(VERSION.semverString)).toMatch(/^\d+\.\d+\.\d+/)
|
||||
expect(String(VERSION.hash)).toMatch(/^g?[0-9a-f]{7,}$/)
|
||||
})
|
||||
})
|
||||
+56
-33
@@ -1568,10 +1568,6 @@ body[cds-theme='light'] {
|
||||
|
||||
// SAS-LOGS-MODAL.COMPONENT
|
||||
app-sas-logs-modal {
|
||||
--line-height: 16px;
|
||||
--line-count: 1;
|
||||
--line-top: 0px;
|
||||
|
||||
.issue-code {
|
||||
margin-right: 4px;
|
||||
font-family: monospace;
|
||||
@@ -1588,50 +1584,77 @@ app-sas-logs-modal {
|
||||
}
|
||||
}
|
||||
|
||||
.line-highlight-overlay {
|
||||
position: absolute;
|
||||
// One row per rendered line, so the number sits on the line it counts and a
|
||||
// wrapped line hangs under the text rather than under the number.
|
||||
.log-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.log-line-number {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
animation: fadeOut 3s forwards;
|
||||
z-index: 10;
|
||||
z-index: 1;
|
||||
flex: 0 0 auto;
|
||||
min-width: 3.5em;
|
||||
padding-right: 0.75em;
|
||||
margin-right: 0.75em;
|
||||
border-right: 1px solid #d9d9d9;
|
||||
// The modal's own background, so a line that is scrolled sideways runs
|
||||
// under the numbers rather than through them, in either theme.
|
||||
background-color: var(--cds-alias-object-container-background, #fff);
|
||||
color: #9a9a9a;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.temp-height-setter {
|
||||
height: calc(var(--line-height) * var(--line-count));
|
||||
.log-line-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.line-position-setter {
|
||||
top: var(--line-top);
|
||||
.log-more {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.7rem;
|
||||
color: #6b6b6b;
|
||||
}
|
||||
|
||||
.temp-line-height-calc {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
height: auto;
|
||||
.log-line.error-highlight {
|
||||
animation: flash-error 3s ease-out forwards;
|
||||
}
|
||||
|
||||
.error-highlight {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
.log-line.warning-highlight {
|
||||
animation: flash-warning 3s ease-out forwards;
|
||||
}
|
||||
|
||||
.warning-highlight {
|
||||
background-color: rgba(242, 175, 0, 0.25);
|
||||
.log-line.note-highlight {
|
||||
animation: flash-note 3s ease-out forwards;
|
||||
}
|
||||
|
||||
.note-highlight {
|
||||
background-color: rgba(0, 120, 210, 0.15);
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
@keyframes flash-error {
|
||||
from {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes flash-warning {
|
||||
from {
|
||||
background-color: rgba(242, 175, 0, 0.25);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flash-note {
|
||||
from {
|
||||
background-color: rgba(0, 120, 210, 0.15);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,18 @@ const gitInfo = gitDescribeSync({
|
||||
dirtySemver: false
|
||||
})
|
||||
|
||||
// git-describe only sees tags reachable from HEAD, and a shallow checkout
|
||||
// (CI clones with a single commit) has none - it then reports the bare commit
|
||||
// hash, so every version in the app reads "v.<hash>". The root package.json
|
||||
// carries the released version (semantic-release bumps it before the shipped
|
||||
// frontend is built), so use that whenever git-describe found no tag.
|
||||
if (!gitInfo.tag) {
|
||||
const rootPackagePath = resolve(__dirname, '..', '..', 'package.json')
|
||||
const rootPackageJson = JSON.parse(readFileSync(rootPackagePath, 'utf-8'))
|
||||
gitInfo.tag = `v${rootPackageJson.version}`
|
||||
gitInfo.semverString = rootPackageJson.version
|
||||
}
|
||||
|
||||
//Reading the installed adapter version
|
||||
const adapterPackagePath = resolve(
|
||||
__dirname,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dcfrontend",
|
||||
"version": "7.21.0",
|
||||
"version": "7.22.0",
|
||||
"description": "Data Controller",
|
||||
"devDependencies": {
|
||||
"@nogoo9/gitleaks": "8.30.1-post.2",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"SASControlTable": [
|
||||
{
|
||||
"ACTION": "MAKEDATA",
|
||||
"ADMIN": "AllUsers"
|
||||
"ADMIN": "AllUsers",
|
||||
"OUTFMT": "JSON"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -325,14 +325,14 @@ const data = {
|
||||
{
|
||||
ODS_TABLE: 'ENGINEHOST',
|
||||
NAME: 'Filename',
|
||||
VALUE: 'C:DataControllerDC996664mpe_x_test.sas7bdat'
|
||||
VALUE: 'C:\\DataController\\DC996664\\mpe_x_test.sas7bdat'
|
||||
},
|
||||
{ ODS_TABLE: 'ENGINEHOST', NAME: 'Release Created', VALUE: '9.0401M7' },
|
||||
{ ODS_TABLE: 'ENGINEHOST', NAME: 'Host Created', VALUE: 'X64_DSRV16' },
|
||||
{
|
||||
ODS_TABLE: 'ENGINEHOST',
|
||||
NAME: 'Owner Name',
|
||||
VALUE: 'BUILTINAdministrators'
|
||||
VALUE: 'BUILTIN\\Administrators'
|
||||
},
|
||||
{ ODS_TABLE: 'ENGINEHOST', NAME: 'File Size', VALUE: ' 1MB' },
|
||||
{ ODS_TABLE: 'ENGINEHOST', NAME: 'File Size (bytes)', VALUE: '1048576' }
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1730
-2752
File diff suppressed because it is too large.
Load diff
@@ -1,73 +1,30 @@
|
||||
_webout = `{"SYSDATE" : "26SEP22"
|
||||
,"SYSTIME" : "08:54"
|
||||
, "fromsas":
|
||||
[
|
||||
{
|
||||
"STRING": "digraph G { concentrate=true; node [style=filled,shape=plain]; labelloc = "t";"
|
||||
},
|
||||
{
|
||||
"STRING": "label=<<table>
|
||||
<tr>
|
||||
<td align='text' colspan='4'>REVERSE Lineage for <b>changed_records</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='right'>Library:<br /></td>
|
||||
<td align='left'>DC_JSLIB</td>
|
||||
<td align='right'>Generated by:</td>
|
||||
<td align='left'>sasdemo</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='right'>Table:</td>
|
||||
<td align='left'>MPE_DATALOADS</td>
|
||||
<td align='right'>Generated on:</td>
|
||||
<td align='left'> 26SEP2022:08:54:38</td>
|
||||
</tr>
|
||||
</table>>"
|
||||
/* A fixed sample of what the SAS service returns for this screen.
|
||||
The members are the same shape; the envelope now comes from the runtime. */
|
||||
webout('OPEN')
|
||||
webout('OBJ', 'fromsas', [
|
||||
{
|
||||
STRING:
|
||||
'digraph G { concentrate=true; node [style=filled,shape=plain]; labelloc = "t";'
|
||||
},
|
||||
{
|
||||
"STRING": "x [label="No lineage found" shape=Mdiamond]}
|
||||
}
|
||||
]
|
||||
, "clickableids":
|
||||
[
|
||||
|
||||
]
|
||||
, "info":
|
||||
[
|
||||
STRING:
|
||||
"label=<<table>\n <tr>\n <td align='text' colspan='4'>REVERSE Lineage for <b>changed_records</b></td>\n </tr>\n <tr>\n <td align='right'>Library:<br /></td>\n <td align='left'>DC_JSLIB</td>\n <td align='right'>Generated by:</td>\n <td align='left'>sasdemo</td>\n </tr>\n <tr>\n <td align='right'>Table:</td>\n <td align='left'>MPE_DATALOADS</td>\n <td align='right'>Generated on:</td>\n <td align='left'> 26SEP2022:08:54:38</td>\n </tr>\n </table>>"
|
||||
},
|
||||
{
|
||||
"COLURI": "",
|
||||
"COLNAME": "changed_records",
|
||||
"TABURI": "OMSOBJ:PhysicalTable\A59LNVZG.BR00002J",
|
||||
"TABNAME": "MPE_DATALOADS",
|
||||
"LIBURI": "OMSOBJ:SASLibrary\A59LNVZG.B500000K",
|
||||
"LIBREF": "DC_JSLIB",
|
||||
"RC": 0
|
||||
STRING: 'x [label="No lineage found" shape=Mdiamond]}'
|
||||
}
|
||||
]
|
||||
, "flatdata":
|
||||
[
|
||||
|
||||
]
|
||||
,"_DEBUG" : ""
|
||||
,"_METAUSER": "sasdemo@SAS"
|
||||
,"_METAPERSON": "sasdemo"
|
||||
,"_PROGRAM" : "/Projects/app/dc/services/lineage/fetchcollineage"
|
||||
,"AUTOEXEC" : "D%3A%5Copt%5Csasinside%5CConfig%5CLev1%5CSASApp%5CStoredProcessServer%5Cautoexec.sas"
|
||||
,"MF_GETUSER" : "sasdemo"
|
||||
,"SYSCC" : "0"
|
||||
,"SYSENCODING" : "wlatin1"
|
||||
,"SYSERRORTEXT" : ""
|
||||
,"SYSHOSTNAME" : "SAS"
|
||||
,"SYSPROCESSID" : "41DD8058137FCED940C5250000000000"
|
||||
,"SYSPROCESSMODE" : "SAS Stored Process Server"
|
||||
,"SYSPROCESSNAME" : ""
|
||||
,"SYSJOBID" : "27448"
|
||||
,"SYSSCPL" : "Linunx"
|
||||
,"SYSSITE" : "123"
|
||||
,"SYSUSERID" : "sassrv"
|
||||
,"SYSVLONG" : "9.04.01M7P080520"
|
||||
,"SYSWARNINGTEXT" : "ENCODING option ignored for files opened with RECFM=N."
|
||||
,"END_DTTM" : "2022-09-26T08:54:39.013000"
|
||||
,"MEMSIZE" : "46GB"
|
||||
])
|
||||
webout('OBJ', 'clickableids', [])
|
||||
webout('OBJ', 'info', [
|
||||
{
|
||||
COLURI: '',
|
||||
COLNAME: 'changed_records',
|
||||
TABURI: 'OMSOBJ:PhysicalTable\\A59LNVZG.BR00002J',
|
||||
TABNAME: 'MPE_DATALOADS',
|
||||
LIBURI: 'OMSOBJ:SASLibrary\\A59LNVZG.B500000K',
|
||||
LIBREF: 'DC_JSLIB',
|
||||
RC: 0
|
||||
}
|
||||
`
|
||||
])
|
||||
webout('OBJ', 'flatdata', [])
|
||||
webout('CLOSE')
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* createdemodata carries the estate the Cypress suite needs but a Data
|
||||
* Controller install does not have: the TESTDATA demo library, the deep and
|
||||
* wide viewer fixtures and the multi-load library.
|
||||
*
|
||||
* It runs after makedata, so it must both add those and leave the control
|
||||
* library alone otherwise; and it must be safe to run twice, because a reseed
|
||||
* runs it twice on the same drive.
|
||||
*
|
||||
* Run with: node --test sas/mocks/test/
|
||||
*/
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { seededEstate, runService, readTable } = require('./harness')
|
||||
|
||||
const FIXTURE_LIBS = ['TESTDATA', 'DC996664']
|
||||
|
||||
test('the fixtures are added to a makedata estate', () => {
|
||||
const estate = seededEstate()
|
||||
const data = path.join(estate.appDir, 'data')
|
||||
assert.deepEqual(fs.readdirSync(data).sort(), [
|
||||
'DC996664',
|
||||
'DC_JSLIB',
|
||||
'TESTDATA'
|
||||
])
|
||||
|
||||
const tables = readTable(estate, 'MPE_TABLES')
|
||||
for (const lib of FIXTURE_LIBS) {
|
||||
assert.ok(
|
||||
tables.some((row) => String(row.libref).toUpperCase() === lib),
|
||||
lib + ' is registered in MPE_TABLES'
|
||||
)
|
||||
}
|
||||
for (const name of ['MPE_X_SEARCH', 'MPE_X_WIDE']) {
|
||||
assert.ok(
|
||||
tables.some((row) => String(row.dsn).toUpperCase() === name),
|
||||
name + ' is registered in MPE_TABLES'
|
||||
)
|
||||
}
|
||||
|
||||
assert.equal(readTable(estate, 'MPE_X_SEARCH').length, 1000)
|
||||
assert.equal(readTable(estate, 'MPE_X_WIDE').length, 200)
|
||||
assert.ok(readTable(estate, 'MPE_DERIVED').length > 0)
|
||||
assert.ok(
|
||||
readTable(estate, 'MPE_VALIDATIONS').some(
|
||||
(row) => String(row.base_lib).toUpperCase() === 'TESTDATA'
|
||||
),
|
||||
'the TESTDATA rules are present'
|
||||
)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(data, 'TESTDATA', 'demo_01.json')),
|
||||
'the TESTDATA tables are written'
|
||||
)
|
||||
})
|
||||
|
||||
test('every fixture table is written with its column metadata', () => {
|
||||
// saveTable falls back to the columns of the file already on the drive, and
|
||||
// a fresh estate has none - so a fixture with no schema of its own is
|
||||
// written with columns: [] and the viewer loses the order it renders.
|
||||
const estate = seededEstate()
|
||||
const tables = [
|
||||
['DC_JSLIB', 'MPE_X_SEARCH'],
|
||||
['DC_JSLIB', 'MPE_X_WIDE'],
|
||||
['TESTDATA', 'MPE_X_NEW'],
|
||||
['TESTDATA', 'DEMO_01'],
|
||||
['DC996664', 'MPE_X_TEST']
|
||||
]
|
||||
for (const [lib, name] of tables) {
|
||||
const file = path.join(
|
||||
estate.appDir,
|
||||
'data',
|
||||
lib,
|
||||
name.toLowerCase() + '.json'
|
||||
)
|
||||
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
assert.ok(table.columns.length > 0, lib + '.' + name + ' has no columns')
|
||||
const described = table.columns.map((column) => column.name.toUpperCase())
|
||||
for (const field of Object.keys(table.rows[0] || {})) {
|
||||
assert.ok(
|
||||
described.includes(field.toUpperCase()),
|
||||
lib + '.' + name + '.' + field + ' has no column metadata'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const search = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(estate.appDir, 'data', 'DC_JSLIB', 'mpe_x_search.json'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
assert.deepEqual(
|
||||
search.columns.map((column) => column.name),
|
||||
[
|
||||
'PRIMARY_KEY_FIELD',
|
||||
'SITE_NAME',
|
||||
'VESSEL',
|
||||
'SAMPLE_COUNT',
|
||||
'DEPTH_M',
|
||||
'SPECIES',
|
||||
'CRUISE_DATE',
|
||||
'EXPEDITION_ID',
|
||||
'NOTES'
|
||||
],
|
||||
'the demo column order the search spec derives its counts from'
|
||||
)
|
||||
})
|
||||
|
||||
test('a second run does not accumulate rows', () => {
|
||||
const estate = seededEstate()
|
||||
const counted = () =>
|
||||
[
|
||||
'MPE_TABLES',
|
||||
'MPE_VALIDATIONS',
|
||||
'MPE_SELECTBOX',
|
||||
'MPE_SECURITY',
|
||||
'MPE_DERIVED'
|
||||
]
|
||||
.map((name) => name + '=' + readTable(estate, name).length)
|
||||
.join(' ')
|
||||
|
||||
const first = counted()
|
||||
runService(estate, 'admin/createdemodata', {})
|
||||
assert.equal(counted(), first, 'createdemodata must be idempotent')
|
||||
})
|
||||
|
||||
test('a derived row makedata owns survives the fixture rows', () => {
|
||||
// MPE_DERIVED is a control table: createdemodata must add its TESTDATA rows
|
||||
// without replacing what makedata wrote, or the first real derived rule on
|
||||
// the estate would vanish on the next reseed.
|
||||
const { writeTable } = require('./harness')
|
||||
const estate = seededEstate()
|
||||
const owned = {
|
||||
tx_from: 0,
|
||||
tx_to: 127490111999,
|
||||
libref: 'DC_JSLIB',
|
||||
dsn: 'MPE_X_TEST',
|
||||
var_nm: 'MOCK_OWNED',
|
||||
var_logic: 'SOME_NUM * 2',
|
||||
var_type: 'N',
|
||||
active: 1
|
||||
}
|
||||
writeTable(estate, 'MPE_DERIVED', readTable(estate, 'MPE_DERIVED').concat([owned]))
|
||||
|
||||
runService(estate, 'admin/createdemodata', {})
|
||||
|
||||
const rows = readTable(estate, 'MPE_DERIVED')
|
||||
assert.ok(
|
||||
rows.some((row) => row.var_nm === 'MOCK_OWNED'),
|
||||
'the makedata-owned row is kept'
|
||||
)
|
||||
assert.equal(
|
||||
rows.filter((row) => row.var_nm === 'MOCK_OWNED').length,
|
||||
1,
|
||||
'and it is not duplicated'
|
||||
)
|
||||
assert.ok(
|
||||
rows.some((row) => String(row.libref).toUpperCase() === 'TESTDATA'),
|
||||
'the fixture rows are still added'
|
||||
)
|
||||
})
|
||||
|
||||
test('it refuses to run before makedata', () => {
|
||||
const { makeEstate } = require('./harness')
|
||||
const estate = makeEstate()
|
||||
assert.throws(
|
||||
() => runService(estate, 'admin/createdemodata', {}),
|
||||
/run services\/admin\/makedata first/,
|
||||
'a missing control library is reported, not half-created'
|
||||
)
|
||||
})
|
||||
@@ -168,12 +168,18 @@ function runService(
|
||||
return stored || returned
|
||||
}
|
||||
|
||||
/** Seeds the estate exactly as the deploy screen does, then returns it. */
|
||||
function seededEstate() {
|
||||
/**
|
||||
* Seeds the estate the way the CI estate is built: makedata for the control
|
||||
* library, then createdemodata for the Cypress fixtures. Pass
|
||||
* `{ demo: false }` for the control library alone, which is what a real
|
||||
* deployment gets.
|
||||
*/
|
||||
function seededEstate({ demo = true } = {}) {
|
||||
const estate = makeEstate()
|
||||
runService(estate, 'admin/makedata', {
|
||||
params: { action: 'MAKEDATA', admin: 'AllUsers' }
|
||||
})
|
||||
if (demo) runService(estate, 'admin/createdemodata', {})
|
||||
return estate
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +383,25 @@ test('the CLI seed route works with its own table name', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('MAKEDATA answers the CLI seed with JSON, and the screen with HTML', () => {
|
||||
// The workflows seed through `sasjs request`, which fails the job when the
|
||||
// body is not JSON - so the payload asks for OUTFMT=JSON, as the SAS service
|
||||
// does. The deploy screen asks for nothing and renders the HTML.
|
||||
const cli = makeEstate()
|
||||
const json = runMakedata(cli, {
|
||||
rows: [{ ACTION: 'MAKEDATA', ADMIN: 'AllUsers', OUTFMT: 'JSON' }]
|
||||
})
|
||||
const body = parse(json)
|
||||
assert.equal(body.makedata_response[0].STATUS, 'SUCCESS')
|
||||
assert.equal(body.makedata_response[0].LIBREF, 'DC_JSLIB')
|
||||
|
||||
const screen = makeEstate()
|
||||
const html = runMakedata(screen, {
|
||||
rows: [{ ACTION: 'MAKEDATA', ADMIN: 'AllUsers' }]
|
||||
})
|
||||
assert.match(html, /<h3>Data Controller Config<\/h3>/)
|
||||
})
|
||||
|
||||
test('MAKEDATA seeds the control library and then removes itself', () => {
|
||||
const estate = makeEstate()
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* The mock makedata must build the estate the SAS service builds.
|
||||
*
|
||||
* makedata.sas (the `server` target) runs `%mpe_makedatamodel` for the empty
|
||||
* tables and `%mpe_makedata` for the rows. The JS mock in
|
||||
* sas/mocks/sasjs/services/admin/makedata.js is a second implementation of
|
||||
* those two macros, and the two used to disagree: two model tables
|
||||
* (MPE_MAXKEYVALUES, MPE_SIGNOFFS) were never created, and a handful of seed
|
||||
* rows were missing. Nothing failed, because the client only asks for the
|
||||
* tables it opens.
|
||||
*
|
||||
* So the macros are read here, and the estate the mock seeds is compared with
|
||||
* what they declare. A change to either side that is not made on the other
|
||||
* fails this test.
|
||||
*
|
||||
* Run with: node --test sas/mocks/test/
|
||||
*/
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { seededEstate, readTable } = require('./harness')
|
||||
|
||||
const MACROS = path.join(__dirname, '..', '..', 'sasjs', 'macros')
|
||||
const MODEL = fs.readFileSync(
|
||||
path.join(MACROS, 'mpe_makedatamodel.sas'),
|
||||
'utf8'
|
||||
)
|
||||
const SEED = fs.readFileSync(path.join(MACROS, 'mpe_makedata.sas'), 'utf8')
|
||||
|
||||
/** `create table &lib..name(` blocks, with their column definitions. */
|
||||
function modelTables(source) {
|
||||
const tables = {}
|
||||
const re = /create table &lib\.\.\s*([A-Za-z0-9_-]+)\s*\(([\s\S]*?)\n\s*\);/gi
|
||||
let match
|
||||
while ((match = re.exec(source)) !== null) {
|
||||
const name = match[1].toUpperCase()
|
||||
const columns = []
|
||||
for (const line of match[2].split('\n')) {
|
||||
const col = /^\s*,?\s*([A-Za-z0-9_]+)\s+(num|char|varchar|float|int)\b/i.exec(
|
||||
line
|
||||
)
|
||||
if (col) columns.push(col[1].toUpperCase())
|
||||
}
|
||||
tables[name] = columns
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
/** `insert into &lib..name set` statements, grouped by table. */
|
||||
function seedInserts(source) {
|
||||
const body = source.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const rows = {}
|
||||
const re = /insert into &lib\.\.\s*([A-Za-z0-9_-]+)\s+set\s+([\s\S]*?);/gi
|
||||
let match
|
||||
while ((match = re.exec(body)) !== null) {
|
||||
const name = match[1].toUpperCase()
|
||||
const columns = []
|
||||
for (const part of match[2].split(/[\n,]/)) {
|
||||
const assignment = /^\s*([A-Za-z0-9_]+)\s*=/.exec(part)
|
||||
if (assignment) columns.push(assignment[1].toUpperCase())
|
||||
}
|
||||
rows[name] = rows[name] || []
|
||||
rows[name].push(columns.sort())
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const model = modelTables(MODEL)
|
||||
const seeds = seedInserts(SEED)
|
||||
|
||||
/*
|
||||
These four carry rows the mock cannot copy verbatim:
|
||||
- MPE_GROUPS, MPE_ROW_LEVEL_SECURITY and MPE_SECURITY name SAS metadata
|
||||
groups, which a JS-only estate does not have (the mock uses its own).
|
||||
- MPE_X_TEST is seeded with ~490 rows from a ranuni() loop, and the mock
|
||||
keeps the seven deterministic rows only.
|
||||
*/
|
||||
const ADAPTED = new Set([
|
||||
'MPE_GROUPS',
|
||||
'MPE_ROW_LEVEL_SECURITY',
|
||||
'MPE_SECURITY',
|
||||
'MPE_X_TEST'
|
||||
])
|
||||
|
||||
test('mpe_makedatamodel declares tables, and the mock creates all of them', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const created = {}
|
||||
const dir = path.join(estate.appDir, 'data', 'DC_JSLIB')
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
const table = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
||||
created[table.name] = table
|
||||
}
|
||||
|
||||
assert.ok(Object.keys(model).length > 30, 'the model was parsed')
|
||||
const missing = Object.keys(model).filter((name) => !created[name])
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
'every table the model creates must exist in the mock estate'
|
||||
)
|
||||
|
||||
const known = new Set(Object.keys(model))
|
||||
// the mock writes a marker for the format catalog, which is a SAS catalog
|
||||
// rather than a dataset, so the model does not declare it
|
||||
known.add('MPE_X_CATALOG')
|
||||
const extra = Object.keys(created).filter((name) => !known.has(name))
|
||||
assert.deepEqual(
|
||||
extra,
|
||||
[],
|
||||
'a new table in the mock estate is a table the SAS service does not build'
|
||||
)
|
||||
})
|
||||
|
||||
test('each table has the model columns, in the model order', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
for (const [name, expected] of Object.entries(model)) {
|
||||
const actual = readTable(estate, name)
|
||||
// readTable returns rows; the schema is read from the file instead
|
||||
const file = path.join(
|
||||
estate.appDir,
|
||||
'data',
|
||||
'DC_JSLIB',
|
||||
name.toLowerCase() + '.json'
|
||||
)
|
||||
assert.ok(fs.existsSync(file), name + ' is missing')
|
||||
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
assert.deepEqual(
|
||||
(table.columns || []).map((col) => col.name),
|
||||
expected,
|
||||
name + ' columns differ from mpe_makedatamodel.sas'
|
||||
)
|
||||
assert.ok(Array.isArray(actual), name + ' rows')
|
||||
}
|
||||
})
|
||||
|
||||
test('each seeded table carries the rows mpe_makedata inserts', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
for (const [name, inserts] of Object.entries(seeds)) {
|
||||
if (ADAPTED.has(name)) continue
|
||||
const rows = readTable(estate, name)
|
||||
assert.equal(
|
||||
rows.length,
|
||||
inserts.length,
|
||||
name + ' row count differs from mpe_makedata.sas'
|
||||
)
|
||||
// a table whose inserts name optional columns (MPE_XLMAP_RULES sets
|
||||
// XLMAP_FINISH on some ranges only) has a union; every row must sit
|
||||
// inside it, and the union must be the one the macro writes
|
||||
const union = new Set()
|
||||
for (const insert of inserts) for (const field of insert) union.add(field)
|
||||
const expected = Array.from(union).sort()
|
||||
for (const row of rows) {
|
||||
const fields = Object.keys(row)
|
||||
.map((field) => field.toUpperCase())
|
||||
.sort()
|
||||
for (const field of fields) {
|
||||
assert.ok(
|
||||
union.has(field),
|
||||
name + ' row carries ' + field + ', which mpe_makedata never sets'
|
||||
)
|
||||
}
|
||||
assert.deepEqual(
|
||||
fields,
|
||||
expected.filter((field) => fields.includes(field)),
|
||||
name + ' row fields differ from mpe_makedata.sas'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the adapted tables are seeded, and document why they differ', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
for (const name of ADAPTED) {
|
||||
const rows = readTable(estate, name)
|
||||
assert.ok(rows.length > 0, name + ' is empty')
|
||||
}
|
||||
// the mock's own groups are the ones its services report
|
||||
const groups = readTable(estate, 'MPE_GROUPS')
|
||||
assert.ok(
|
||||
groups.some((row) => row.group_name === 'dc-admin'),
|
||||
'the mock grants its own group, not a SAS metadata one'
|
||||
)
|
||||
})
|
||||
|
||||
test('every seeded value fits its column type', () => {
|
||||
// The rows are transcribed from SAS source, where a value can be a macro
|
||||
// expression. The mock has no macro engine, so a transcription that leaves
|
||||
// one behind (a %mf_increment(rk) surrogate key, a quoted '0A'x newline)
|
||||
// writes a string where the client expects a number or a line break.
|
||||
const estate = seededEstate({ demo: false })
|
||||
for (const name of Object.keys(model)) {
|
||||
const file = path.join(
|
||||
estate.appDir,
|
||||
'data',
|
||||
'DC_JSLIB',
|
||||
name.toLowerCase() + '.json'
|
||||
)
|
||||
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
const types = {}
|
||||
for (const col of table.columns) types[col.name.toUpperCase()] = col.type
|
||||
for (const row of table.rows) {
|
||||
for (const [field, value] of Object.entries(row)) {
|
||||
const type = types[field.toUpperCase()]
|
||||
if (value === null || value === undefined) continue
|
||||
if (type === 'N') {
|
||||
assert.equal(
|
||||
typeof value,
|
||||
'number',
|
||||
name + '.' + field + ' is numeric but carries ' + JSON.stringify(value)
|
||||
)
|
||||
} else {
|
||||
assert.equal(
|
||||
typeof value,
|
||||
'string',
|
||||
name + '.' + field + ' is character but carries ' + JSON.stringify(value)
|
||||
)
|
||||
assert.ok(
|
||||
value.indexOf('\\u0001') < 0,
|
||||
name + '.' + field + ' still holds an escaped newline'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('makedata leaves no Cypress fixture behind', () => {
|
||||
const estate = seededEstate({ demo: false })
|
||||
const dir = path.join(estate.appDir, 'data')
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(dir).sort(),
|
||||
['DC_JSLIB'],
|
||||
'the demo libraries belong to createdemodata, not makedata'
|
||||
)
|
||||
const tables = readTable(estate, 'MPE_TABLES')
|
||||
const fixture = tables.filter(
|
||||
(row) => String(row.libref).toUpperCase() !== 'DC_JSLIB'
|
||||
)
|
||||
assert.deepEqual(fixture, [], 'a fixture table is registered by makedata')
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Separators in the SAS 9 mock tree.
|
||||
*
|
||||
* The client splits these values on the backslash (`viewer.component.ts`,
|
||||
* `dc-tree.component.ts`, `metadata.component.ts`), so a value that lost one -
|
||||
* `OMSOBJ:SASLibraryA59...` where SAS writes `OMSOBJ:SASLibrary\A59...` - or
|
||||
* carries two sends it down a different branch.
|
||||
*
|
||||
* This replays every service and refuses a separator in any member it emits,
|
||||
* in either fault shape: missing where SAS writes one, or doubled. A value in
|
||||
* a shared module that never reaches a member is outside this test, and never
|
||||
* reaches the browser either.
|
||||
*
|
||||
* Run with: node --test sas/mocks/test/
|
||||
*/
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
|
||||
const SERVICES = path.join(__dirname, '..', 'sas9', 'sasjs', 'services')
|
||||
const BACKSLASH = 92
|
||||
|
||||
function walk(dir) {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.flatMap((entry) =>
|
||||
entry.isDirectory()
|
||||
? walk(path.join(dir, entry.name))
|
||||
: [path.join(dir, entry.name)]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Files that are not services. A file that does not emit through webout() and
|
||||
* is not named here fails the scope test, so a rename of webout() or a move to
|
||||
* another helper shrinks the run set loudly rather than silently.
|
||||
*/
|
||||
const HELPERS = ['weboutGuard.js']
|
||||
|
||||
/** Every captured service, in a stable order. */
|
||||
function serviceFiles() {
|
||||
return walk(SERVICES)
|
||||
.filter((file) => file.endsWith('.js'))
|
||||
.filter((file) => !HELPERS.includes(path.basename(file)))
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* The services this harness can run: the ones that emit through webout(). A
|
||||
* shared module that only exports helpers is skipped here, and the test below
|
||||
* still reads every file, so a service that assigns the body itself cannot
|
||||
* hide behind the filter.
|
||||
*/
|
||||
function emittingFiles() {
|
||||
return serviceFiles().filter((file) =>
|
||||
/(^|[^.\w])webout\s*\(/.test(fs.readFileSync(file, 'utf8'))
|
||||
)
|
||||
}
|
||||
|
||||
/** Runs a service the way the SASjs Server runtime does, keeping its members. */
|
||||
function runService(file) {
|
||||
const members = {}
|
||||
const sandbox = {
|
||||
// every member a service emits, whatever kind it used
|
||||
webout: (kind, name, rows) => {
|
||||
if (typeof name === 'string' && rows !== undefined) members[name] = rows
|
||||
},
|
||||
// the guard is not what this test is about, so it is stubbed. Everything
|
||||
// else resolves the way the runtime resolves it for a service: a relative
|
||||
// request from the service's own directory, a builtin from node
|
||||
require: (request) => {
|
||||
const spec = String(request)
|
||||
if (spec.endsWith('weboutGuard.js')) return () => {}
|
||||
return spec.startsWith('.')
|
||||
? require(path.resolve(path.dirname(file), spec))
|
||||
: require(spec)
|
||||
},
|
||||
fs,
|
||||
_program: path.resolve(file),
|
||||
weboutPath: path.join(path.dirname(file), 'body.json')
|
||||
}
|
||||
try {
|
||||
vm.runInNewContext(fs.readFileSync(file, 'utf8'), sandbox, {
|
||||
filename: file
|
||||
})
|
||||
} catch (error) {
|
||||
// a missing global in the sandbox must name the service that wanted it
|
||||
error.message = `${path.relative(SERVICES, file)}: ${error.message}`
|
||||
throw error
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
/** Every leaf of a member's rows, with its path for the failure message. */
|
||||
function leaves(node, prefix, out) {
|
||||
if (node === null || typeof node !== 'object') out.push([prefix, node])
|
||||
else if (Array.isArray(node)) {
|
||||
node.forEach((item, i) => leaves(item, `${prefix}[${i}]`, out))
|
||||
} else {
|
||||
for (const key of Object.keys(node)) {
|
||||
leaves(node[key], `${prefix}.${key}`, out)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function faultsIn(value) {
|
||||
const faults = []
|
||||
|
||||
// A backslash run of two is legitimate only as a UNC prefix
|
||||
// (`\\server\share`); the data has no other use for two separators in a row,
|
||||
// and a run of three never happens.
|
||||
for (
|
||||
let at = value.indexOf('\\\\');
|
||||
at >= 0;
|
||||
at = value.indexOf('\\\\', at + 1)
|
||||
) {
|
||||
let end = at + 2
|
||||
while (value.charCodeAt(end) === BACKSLASH) end++
|
||||
const before = at === 0 ? '' : value[at - 1]
|
||||
// a path keeps its prefix wherever a value can put one - after a tag, a
|
||||
// quote, a comma, a space - but not straight after a word character, a
|
||||
// colon (C:\\ is the drive fault) or another separator
|
||||
const uncPrefix = end - at === 2 && !/[\w:\\]/.test(before)
|
||||
if (!uncPrefix) {
|
||||
faults.push('doubled separator')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// OMSOBJ:<Class>\<id> - the class name is that side of the separator
|
||||
for (
|
||||
let at = value.indexOf('OMSOBJ:');
|
||||
at >= 0;
|
||||
at = value.indexOf('OMSOBJ:', at + 7)
|
||||
) {
|
||||
let after = at + 'OMSOBJ:'.length
|
||||
while (after < value.length && /[A-Za-z]/.test(value[after])) after++
|
||||
if (after === at + 7) faults.push('OMSOBJ: with no class name')
|
||||
else if (value.charCodeAt(after) !== BACKSLASH) {
|
||||
faults.push('OMSOBJ separator missing')
|
||||
}
|
||||
}
|
||||
|
||||
// C:\folder\file.sas7bdat
|
||||
for (let i = 0; i < value.length - 1; i++) {
|
||||
const letter = /[A-Za-z]/.test(value[i])
|
||||
const before = i === 0 ? '' : value[i - 1]
|
||||
// path-initial: nothing word-like in front, so a delimiter of any shape
|
||||
// (<, >, =, [, comma, quote, space) still introduces a drive
|
||||
const atStart = before === '' || !/\w/.test(before)
|
||||
if (letter && value[i + 1] === ':' && atStart) {
|
||||
if (value.charCodeAt(i + 2) !== BACKSLASH) {
|
||||
faults.push('drive separator missing')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUILTIN\Administrators, DOMAIN\user
|
||||
for (const lead of ['BUILTIN', 'DOMAIN']) {
|
||||
for (
|
||||
let at = value.indexOf(lead);
|
||||
at >= 0;
|
||||
at = value.indexOf(lead, at + lead.length)
|
||||
) {
|
||||
const after = at + lead.length
|
||||
if (/[A-Za-z]/.test(value[after] || '')) {
|
||||
if (value.charCodeAt(after) !== BACKSLASH) {
|
||||
faults.push(`${lead} separator missing`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return faults
|
||||
}
|
||||
|
||||
test('every file is a service that emits or a listed helper', () => {
|
||||
const emitting = new Set(emittingFiles())
|
||||
const skipped = serviceFiles()
|
||||
.filter((file) => !emitting.has(file))
|
||||
.map((file) => path.relative(SERVICES, file))
|
||||
assert.deepStrictEqual(
|
||||
skipped,
|
||||
[],
|
||||
`not emitting through webout() and not a helper: ${skipped.join(', ')}`
|
||||
)
|
||||
// the tree holds 35 services; a wholesale rename of webout() must not
|
||||
// leave this suite scanning nothing
|
||||
assert.ok(emitting.size > 30, `only ${emitting.size} services ran`)
|
||||
})
|
||||
|
||||
test('every sas9 service emits its separators once', () => {
|
||||
const failures = []
|
||||
for (const file of emittingFiles()) {
|
||||
const rel = path.relative(SERVICES, file)
|
||||
const members = runService(file)
|
||||
if (Object.keys(members).length === 0) {
|
||||
// a service that emits nothing would otherwise pass this test with
|
||||
// nothing checked
|
||||
failures.push(`${rel} [no members captured]`)
|
||||
continue
|
||||
}
|
||||
for (const [key, value] of leaves(members, '', [])) {
|
||||
if (typeof value !== 'string') continue
|
||||
for (const fault of faultsIn(value)) {
|
||||
failures.push(`${rel} ${key} [${fault}] ${JSON.stringify(value)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(failures, [], failures.join('\n'))
|
||||
})
|
||||
|
||||
test('the getdata ENGINEHOST rows carry the separators SAS writes', () => {
|
||||
const members = runService(path.join(SERVICES, 'editors', 'getdata.js'))
|
||||
const rows = members.dsmeta.filter((row) => row.ODS_TABLE === 'ENGINEHOST')
|
||||
const value = (name) => rows.find((row) => row.NAME === name).VALUE
|
||||
|
||||
// the library the recording was written to, per public/refreshlibinfo.js
|
||||
assert.strictEqual(
|
||||
value('Filename'),
|
||||
'C:\\DataController\\DC996664\\mpe_x_test.sas7bdat'
|
||||
)
|
||||
assert.strictEqual(value('Owner Name'), 'BUILTIN\\Administrators')
|
||||
})
|
||||
|
||||
test('a UNC path is not a doubled separator', () => {
|
||||
// built from the code point so no escape can eat a separator on the way in
|
||||
const sep = String.fromCharCode(BACKSLASH)
|
||||
const unc = sep + sep + 'fileserver' + sep + 'share' + sep + 'mpe.sas7bdat'
|
||||
assert.ok(unc.startsWith(sep + sep) && unc[2] !== sep, 'not a UNC prefix')
|
||||
|
||||
// a legitimate library path, wherever a value can put one
|
||||
assert.deepStrictEqual(faultsIn(unc), [])
|
||||
assert.deepStrictEqual(faultsIn(`LIBURI: '${unc}'`), [])
|
||||
assert.deepStrictEqual(faultsIn(`<Path>${unc}</Path>`), [])
|
||||
assert.deepStrictEqual(faultsIn(`a,${unc}`), [])
|
||||
|
||||
// a separator written twice where one belongs is still a fault
|
||||
assert.deepStrictEqual(
|
||||
faultsIn(`C:${sep}${sep}DataController${sep}mpe.sas7bdat`),
|
||||
['doubled separator']
|
||||
)
|
||||
assert.deepStrictEqual(faultsIn(`OMSOBJ:SASLibrary${sep}${sep}A59LNVZG`), [
|
||||
'doubled separator'
|
||||
])
|
||||
})
|
||||
|
||||
test('a separator missing where SAS writes one is a fault', () => {
|
||||
assert.deepStrictEqual(
|
||||
faultsIn('C:DataControllerDC996664mpe_x_test.sas7bdat'),
|
||||
['drive separator missing']
|
||||
)
|
||||
assert.deepStrictEqual(faultsIn('OMSOBJ:SASLibraryA59LNVZG.B500000K'), [
|
||||
'OMSOBJ separator missing'
|
||||
])
|
||||
assert.deepStrictEqual(faultsIn('BUILTINAdministrators'), [
|
||||
'BUILTIN separator missing'
|
||||
])
|
||||
assert.deepStrictEqual(faultsIn('DOMAINalice'), ['DOMAIN separator missing'])
|
||||
})
|
||||
|
||||
test('a drive introduced by a delimiter is checked', () => {
|
||||
for (const lead of ['<td>', 'k=', '[', ',', 'a, ']) {
|
||||
assert.deepStrictEqual(
|
||||
faultsIn(`${lead}C:DataController`),
|
||||
['drive separator missing'],
|
||||
lead
|
||||
)
|
||||
}
|
||||
// a letter that merely precedes a colon in prose is not a drive
|
||||
assert.deepStrictEqual(faultsIn('Library: DC996664'), [])
|
||||
})
|
||||
|
||||
test('no sas9 service assigns the response body directly', () => {
|
||||
const offenders = serviceFiles().filter((file) =>
|
||||
fs.readFileSync(file, 'utf8').includes('_webout')
|
||||
)
|
||||
assert.deepStrictEqual(
|
||||
offenders.map((file) => path.relative(SERVICES, file)),
|
||||
[]
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The sasjs mock tree's response shape.
|
||||
*
|
||||
* Every service under sas/mocks/sasjs/services emits its members through
|
||||
* `webout()`, so the response envelope comes from the runtime. A few write the
|
||||
* whole body themselves with content headers of their own - text, CSV, DDL,
|
||||
* HTML, file downloads - and are listed here. A service that assigns `_webout`
|
||||
* and is not on that list has gone back to freezing the envelope at the
|
||||
* recording, which is what `CONTRIBUTING.md` describes.
|
||||
*
|
||||
* Run with: node --test sas/mocks/test/
|
||||
*/
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
|
||||
const SERVICES = path.join(__dirname, '..', 'sasjs', 'services')
|
||||
|
||||
/** The services whose response is not a JSON body of members. */
|
||||
const PLAIN_BODY = [
|
||||
'admin/exportconfig.js',
|
||||
'admin/exportdb.js',
|
||||
'admin/makedata.js',
|
||||
'auditors/getauditfile.js',
|
||||
'public/getddl.js',
|
||||
'public/getrawdata.js'
|
||||
]
|
||||
|
||||
function walk(dir) {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.flatMap((entry) =>
|
||||
entry.isDirectory()
|
||||
? walk(path.join(dir, entry.name))
|
||||
: [path.join(dir, entry.name)]
|
||||
)
|
||||
}
|
||||
|
||||
function serviceFiles() {
|
||||
return walk(SERVICES)
|
||||
.filter((file) => file.endsWith('.js'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
/** Runs a service the way the SASjs Server runtime does, keeping its members. */
|
||||
function runService(file) {
|
||||
const members = {}
|
||||
const sandbox = {
|
||||
webout: (kind, name, rows) => {
|
||||
if (typeof name === 'string' && rows !== undefined) members[name] = rows
|
||||
},
|
||||
require: (request) => {
|
||||
const spec = String(request)
|
||||
return spec.startsWith('.')
|
||||
? require(path.resolve(path.dirname(file), spec))
|
||||
: require(spec)
|
||||
},
|
||||
fs,
|
||||
_program: path.resolve(file),
|
||||
weboutPath: path.join(path.dirname(file), 'body.json')
|
||||
}
|
||||
try {
|
||||
vm.runInNewContext(fs.readFileSync(file, 'utf8'), sandbox, {
|
||||
filename: file
|
||||
})
|
||||
} catch (error) {
|
||||
error.message = `${path.relative(SERVICES, file)}: ${error.message}`
|
||||
throw error
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
test('only the plain-body services assign the response themselves', () => {
|
||||
const offenders = serviceFiles()
|
||||
.filter((file) => fs.readFileSync(file, 'utf8').includes('_webout'))
|
||||
.map((file) => path.relative(SERVICES, file))
|
||||
assert.deepStrictEqual(offenders, PLAIN_BODY)
|
||||
})
|
||||
|
||||
test('the lineage capture emits the recorded members', () => {
|
||||
const file = path.join(SERVICES, 'lineage', 'fetchlineage.js')
|
||||
const members = runService(file)
|
||||
|
||||
assert.deepStrictEqual(Object.keys(members), [
|
||||
'fromsas',
|
||||
'clickableids',
|
||||
'info',
|
||||
'flatdata'
|
||||
])
|
||||
assert.strictEqual(members.fromsas.length, 3)
|
||||
assert.match(members.fromsas[0].STRING, /^digraph G \{/)
|
||||
assert.match(
|
||||
members.fromsas[1].STRING,
|
||||
/REVERSE Lineage for <b>changed_records<\/b>/
|
||||
)
|
||||
assert.ok(
|
||||
members.fromsas[1].STRING.includes("<td align='left'>DC_JSLIB</td>")
|
||||
)
|
||||
assert.strictEqual(
|
||||
members.fromsas[2].STRING,
|
||||
'x [label="No lineage found" shape=Mdiamond]}'
|
||||
)
|
||||
|
||||
// members come from the sandbox realm, so compare their JSON shape
|
||||
const emitted = JSON.parse(JSON.stringify(members))
|
||||
|
||||
assert.deepStrictEqual(emitted.info, [
|
||||
{
|
||||
COLURI: '',
|
||||
COLNAME: 'changed_records',
|
||||
TABURI: 'OMSOBJ:PhysicalTable\\A59LNVZG.BR00002J',
|
||||
TABNAME: 'MPE_DATALOADS',
|
||||
LIBURI: 'OMSOBJ:SASLibrary\\A59LNVZG.B500000K',
|
||||
LIBREF: 'DC_JSLIB',
|
||||
RC: 0
|
||||
}
|
||||
])
|
||||
assert.deepStrictEqual(emitted.clickableids, [])
|
||||
assert.deepStrictEqual(emitted.flatdata, [])
|
||||
})
|
||||
@@ -30,7 +30,6 @@
|
||||
@li mf_mkdir.sas
|
||||
@li mp_abort.sas
|
||||
@li mpe_action.sas
|
||||
@li ms_getfile.sas
|
||||
@li mpe_dirlist.sas
|
||||
@li mpe_writecheck.sas
|
||||
@li mpe_makedata.sas
|
||||
@@ -274,37 +273,15 @@ run;
|
||||
%mend makedata_body;
|
||||
|
||||
/**
|
||||
* Refuses the browse actions once this appLoc has a settings.sas.
|
||||
* The browse actions need no gate of their own.
|
||||
*
|
||||
* The deploy window closes when MAKEDATA writes that file, and a run that
|
||||
* failed after writing it leaves the file behind - so its presence, not the
|
||||
* survival of this STP, is what says the system is configured. Without this
|
||||
* a failed or abandoned install leaves a caller with a directory listing of
|
||||
* any path the SAS user can read.
|
||||
*
|
||||
* A probe that cannot answer is treated as configured: an unreachable drive
|
||||
* is not evidence that the deployment window is still open.
|
||||
* DIRLIST and WRITECHECK exist for the deploy screen, and the deploy window
|
||||
* closes by itself: a successful run deletes this program, so once DC is
|
||||
* configured there is nothing here left to call. A gate keyed off
|
||||
* settings.sas closed the window on the runs that still needed it - a failed
|
||||
* install leaves that file behind, and so does any earlier configuration of
|
||||
* the same appLoc. That gate closed the very window it was meant to protect.
|
||||
*/
|
||||
%macro browse_gate(iftrue=0);
|
||||
%local status;
|
||||
%if not(&iftrue) %then %return;
|
||||
%let status=0;
|
||||
%ms_getfile(%mf_getapploc()/services/public/settings.sas,outref=dcstngs)
|
||||
%if %symexist(SYS_PROCHTTP_STATUS_CODE) %then
|
||||
%let status=&SYS_PROCHTTP_STATUS_CODE;
|
||||
/* the probe is allowed to 404, so the status is recorded and cleared */
|
||||
%let syscc=0;
|
||||
%mp_abort(iftrue= (&status=200)
|
||||
,mac=&_program
|
||||
,msg=%str(DIRLIST and WRITECHECK are only available until DC is configured)
|
||||
)
|
||||
%mp_abort(iftrue= (&status ne 200 and &status ne 404)
|
||||
,mac=&_program
|
||||
,msg=%str(Could not check whether this appLoc is configured - probe &status)
|
||||
)
|
||||
%mend browse_gate;
|
||||
|
||||
%browse_gate(iftrue= (&action=DIRLIST or &action=WRITECHECK))
|
||||
|
||||
/*
|
||||
Three invocations rather than one branch: each macro takes `iftrue=` and
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
is asserted against the macros directly (macros/mpe_dirlist.test.sas,
|
||||
macros/mpe_writecheck.test.sas); this file covers the dispatch around them.
|
||||
|
||||
The browse actions are only reachable while the appLoc has no settings.sas,
|
||||
and any appLoc running this suite has one - so here the two actions are
|
||||
expected to be refused by the gate. What that proves is that the action
|
||||
reached the gate at all, which is the part the dispatch owns.
|
||||
The browse actions carry no gate of their own: a successful run deletes this
|
||||
program, so the deploy window closes by itself. One of the tests below calls
|
||||
DIRLIST on an appLoc that already has a settings.sas, which is what a retry
|
||||
after a failed install looks like.
|
||||
|
||||
<h4> SAS Macros </h4>
|
||||
@li mpe_assert_abort.sas
|
||||
@@ -82,49 +82,16 @@ run;
|
||||
outds=work.test_results
|
||||
)
|
||||
|
||||
/**
|
||||
* Test 4 - DIRLIST is refused once the appLoc is configured. A run that
|
||||
failed after writing settings.sas leaves it behind, so the file is the
|
||||
test, not the survival of this STP.
|
||||
*/
|
||||
data work.fromjs;
|
||||
length action $10 parent $1000;
|
||||
action='DIRLIST';
|
||||
parent='/tmp';
|
||||
run;
|
||||
%mpe_execute(&_program,
|
||||
viyacontext=&defaultcontext,
|
||||
inputdatasets=work.fromjs,
|
||||
outlib=web4,
|
||||
failfast=0
|
||||
)
|
||||
%mpe_assert_abort(
|
||||
desc=DIRLIST is refused once the appLoc is configured,
|
||||
outstatus=work.dc_childstatus,
|
||||
msg=%str(DIRLIST and WRITECHECK are only available until DC is configured),
|
||||
outds=work.test_results
|
||||
)
|
||||
/*
|
||||
There is no test here for the browse actions themselves.
|
||||
|
||||
/**
|
||||
* Test 5 - and so is WRITECHECK
|
||||
*/
|
||||
data work.fromjs;
|
||||
length action $10 path $1000;
|
||||
action='WRITECHECK';
|
||||
path='/tmp';
|
||||
run;
|
||||
%mpe_execute(&_program,
|
||||
viyacontext=&defaultcontext,
|
||||
inputdatasets=work.fromjs,
|
||||
outlib=web5,
|
||||
failfast=0
|
||||
)
|
||||
%mpe_assert_abort(
|
||||
desc=WRITECHECK is refused once the appLoc is configured,
|
||||
outstatus=work.dc_childstatus,
|
||||
msg=%str(DIRLIST and WRITECHECK are only available until DC is configured),
|
||||
outds=work.test_results
|
||||
)
|
||||
They used to be refused once settings.sas existed, and that refusal is what
|
||||
these two used to assert. The gate is gone: the deploy window closes when a
|
||||
successful run deletes this program, so the window and the program have the
|
||||
same lifetime. DIRLIST and WRITECHECK are asserted against the macros that
|
||||
implement them (macros/mpe_dirlist.test.sas, macros/mpe_writecheck.test.sas);
|
||||
the dispatch around them is what tests 1-3 cover.
|
||||
*/
|
||||
|
||||
/**
|
||||
* dump results to the log for offline inspection
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
@li mf_trimstr.sas
|
||||
@li mp_abort.sas
|
||||
@li mpe_action.sas
|
||||
@li mfv_existfile.sas
|
||||
@li mpe_dirlist.sas
|
||||
@li mpe_writecheck.sas
|
||||
@li mpe_makedata.sas
|
||||
@@ -366,25 +365,15 @@ run;
|
||||
%mend makedata_body;
|
||||
|
||||
/**
|
||||
* Refuses the browse actions once this appLoc has a settings.sas.
|
||||
* The browse actions need no gate of their own.
|
||||
*
|
||||
* The deploy window closes when MAKEDATA writes that file, and a run that
|
||||
* failed after writing it leaves the file behind - so its presence, not the
|
||||
* survival of this STP, is what says the system is configured. Without this
|
||||
* a failed or abandoned install leaves a caller with a directory listing of
|
||||
* any path the SAS user can read.
|
||||
* DIRLIST and WRITECHECK exist for the deploy screen, and the deploy window
|
||||
* closes by itself: a successful run deletes this program, so once DC is
|
||||
* configured there is nothing here left to call. A gate keyed off
|
||||
* settings.sas closed the window on the runs that still needed it - a failed
|
||||
* install leaves that file behind, and so does any earlier configuration of
|
||||
* the same appLoc. That gate closed the very window it was meant to protect.
|
||||
*/
|
||||
%macro browse_gate(iftrue=0);
|
||||
%local configured;
|
||||
%if not(&iftrue) %then %return;
|
||||
%let configured=%mfv_existfile(%mf_getapploc()/services/settings.sas);
|
||||
%mp_abort(iftrue= (&configured=1)
|
||||
,mac=&_program
|
||||
,msg=%str(DIRLIST and WRITECHECK are only available until DC is configured)
|
||||
)
|
||||
%mend browse_gate;
|
||||
|
||||
%browse_gate(iftrue= (&action=DIRLIST or &action=WRITECHECK))
|
||||
|
||||
/*
|
||||
Three invocations rather than one branch: each macro takes `iftrue=` and
|
||||
|
||||
Reference in new issue
Block a user