Compare commits

...
Author SHA1 Message Date
dc 92ff21b8f5 test(licensing): wait for the app to settle instead of sleeping through it
Build / Build-and-ng-test (pull_request) Successful in 3m51s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m22s
Build / Build-and-test-development (pull_request) Failing after 48m15s
Every one of the spec's 52 cy.wait() calls is gone: they ranged from 1s to
20s, 350 seconds of declared sleeping in a spec that runs in 87. Each stood in
for something the app does asynchronously, and each has been replaced by the
state it was waiting for:

- a key change leaves the app re-bootstrapping its licence state, so
  waitForAppSettled() waits for .app-loading to clear and for the app to land
  on a known screen (nav tree, demo banner, key error or login form);
- the paste form exists only on licensing/update - asking whether the app is
  anywhere under licensing/ also matches the error page a bad key produces,
  which has no paste button, so gotoLicensingUpdate() navigates unless that is
  where it already is;
- a rejected key redirects to the licensing page, so verifyLicensingPage()
  waits for that URL instead of sleeping and then checking once, and fails
  there rather than hanging when the redirect never comes;
- the log-out, approve and paste-button paths already retry, so the sleeps in
  front of them were pure padding.

Measured on the local stack against the same estate: 10 passing in 45s twice,
against 1:27 before (and 1:33 in CI).
2026-11-06 16:11:52 +00:00
allan 78b7de7120 Merge pull request 'refactor(mocks): build the control library makedata.sas builds' (#487) from feat/mocks-makedata-parity into release/7.23
Sites / deploy (push) Successful in 3m58s
Build / Build-and-ng-test (pull_request) Successful in 5m5s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m5s
Build / Build-and-test-development (pull_request) Failing after 48m9s
Reviewed-on: #487
2026-11-06 14:43:40 +00:00
dc 6e0866d876 fix(mocks): give MPE_X_SEARCH its column metadata back
Build / Build-and-ng-test (pull_request) Successful in 4m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m13s
Build / Build-and-test-development (pull_request) Failing after 49m40s
The fixture's schema entry lived in makedata's schema object (as a second,
later key in the same literal) and did not survive the move to createdemodata.
saveTable falls back to the file already on the drive, so a reseed looked
fine - but CI deletes the appLoc before seeding, and a fresh estate wrote
mpe_x_search.json with columns: [], losing the column order the
full-table-search spec derives its counts from.

Carry the nine columns (name/type/length/format/label/notnull) in
createdemodata's schema, and add a case that every fixture table is written
with column metadata covering its rows, plus the MPE_X_SEARCH order. It fails
against the previous service.
2026-11-06 14:42:56 +00:00
allan 01d54041a2 Merge pull request 'test(viewbox): select a picker table without walking the tree' (#486) from perf/viewbox-spec into release/7.23
Sites / deploy (push) Canceled after 1m1s
Build / Build-and-ng-test (pull_request) Canceled after 28s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s
Reviewed-on: #486
2026-11-06 14:39:59 +00:00
dc 0e0e79ef63 test(viewbox): match the configured viewbox by its full lib.dataset
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m1s
Build / Build-and-test-development (pull_request) Successful in 24m20s
The panel heading reads 'Configuration (#2 DC_JSLIB.MPE_ALERTS)', so the check
parses lib.dataset out of it and compares it whole. A substring test accepted
another library's table of the same name - this estate seeds MPE_X_TEST under
two libraries - and any name that merely contains the target, which is the
mix-up the check exists to catch.

Verified on the local stack: 12 passing in 2:24 and 2:22.
2026-11-06 14:33:28 +00:00
dc 112e8e4b8b fix(mocks): keep the derived rows makedata owns
Build / Build-and-ng-test (pull_request) Successful in 4m0s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m43s
Build / Build-and-test-development (pull_request) Canceled after 31m43s
createdemodata assigned tables.MPE_DERIVED outright, so any row makedata wrote
there would be wiped by a fixture run - and the idempotency test did not count
the table, so nothing would have said so. Filter the TESTDATA rows out and
append, in the same shape as the tables above, and count MPE_DERIVED in the
idempotency test.

New case: a non-TESTDATA derived row survives a createdemodata run exactly
once. It fails against the previous service and passes now.
2026-11-06 14:02:33 +00:00
dc b5fe46931f test(viewbox): scope the picker lookup, escape the library name, outwait the re-select
Build / Build-and-ng-test (pull_request) Successful in 3m49s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m7s
Build / Build-and-test-development (pull_request) Successful in 24m26s
The table link is looked up inside the matched library node rather than across
the whole picker, so a table of the same name under another expanded library
cannot be clicked instead, and the library fragment is escaped before it
becomes a RegExp - a dot or a bracket in a library name no longer matches more
than the intended node.

The configuration panel is checked after the ~600-800ms window in which
loadData() re-selects the viewbox it just loaded, rather than 400ms into it,
so a moved panel is caught at the click instead of in the column list that
follows.

Verified on the local stack: 12 passing in 2:24 and 2:22.
2026-11-06 14:00:48 +00:00
dc 5f89314aaf fix(mocks): answer the review on #487
Build / Build-and-ng-test (pull_request) Successful in 3m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m13s
Build / Build-and-test-development (pull_request) Canceled after 22m0s
- createdemodata drops the unused servicesDir binding.
- the DC996664 copy no longer swallows a failure: a broken multi-load fixture
  now fails the seed where it happened instead of surfacing in a Cypress spec.
- makedata answers OUTFMT=JSON with a makedata_response member (as the SAS
  service does) and the CLI payload asks for it, so the `|| true` comes off
  makedata in build.yaml, release.yaml and sites.yaml - a failed seed names the
  service that failed instead of reappearing as "createdemodata needs the
  control library".
- the makedata comment points at sas/mocks/test/makedata-parity.test.js, not a
  macro that does not exist.
2026-11-06 13:36:26 +00:00
dc a6cfb12579 refactor(mocks): build the control library makedata.sas builds
Build / Build-and-ng-test (pull_request) Successful in 4m26s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 4m56s
makedata seeded two things at once: the control library, and the tables the
Cypress suite needs. The estate it produced was therefore not the estate a
SAS deployment produces - and it had drifted: MPE_MAXKEYVALUES and
MPE_SIGNOFFS were never created, and MPE_CONFIG, MPE_VALIDATIONS, MPE_TABLES,
MPE_SELECTBOX and MPE_XLMAP_RULES were missing seed rows that mpe_makedata.sas
inserts.

makedata now transcribes mpe_makedatamodel.sas and mpe_makedata.sas: every
model table with its columns, and every seed row the macro writes. The
Cypress fixtures move to services/admin/createdemodata.js, which the test
estate runs after makedata; it strips and re-adds its rows so a reseed does
not accumulate.

Checked against real SAS output on the shared estate (fresh deploy of the
server target, makedata, then getrawdata for each control table and a
dictionary.columns probe): all 40 tables and 339 columns match on name, type,
length, format and label, and the seeded rows match row for row apart from
the environment (libref, user id, group names) and the sample rows. That run
found six defects a source-only comparison could not: INT columns are 8 bytes
not 4, one label had lost a double space, the DC_MACROS setting held the SAS
expression instead of a path, the email templates held an escaped newline,
75 selectbox rows held %mf_increment(rk) instead of their key, and one
validation row was truncated at a ';' inside its regex.

makedata-parity.test.js parses both macros and compares them with the seeded
estate, and fails on a value that does not fit its column type, so the next
drift fails a test instead of shipping. The CI workflows, the Sites demo seed
and CONTRIBUTING call createdemodata after makedata.
2026-11-06 13:29:14 +00:00
dc acf44dae74 test(viewbox): select the config panel by its own heading
Build / Build-and-ng-test (pull_request) Successful in 3m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m2s
Build / Build-and-test-development (pull_request) Canceled after 26m52s
loadData() re-selects a viewbox about 600ms after its data lands, and that can
land after the row click that opens its configuration - leaving the panel on
the other viewbox and the test looking for a column that table does not have.
The panel's heading names the viewbox it is showing, so the click is repeated
until the heading matches rather than trusting the click once.

The row is only clicked when the panel is not already on that viewbox, since
selectViewbox() toggles and a second click would deselect it.

Verified on the local stack: 12 passing in 2:30 and 2:20, against 5:22 for the
same spec before the change.
2026-11-06 13:27:25 +00:00
allan 712f74db6d Merge pull request 'feat(logs): number the lines of the log, the source and the generated code' (#485) from feat/logs-line-numbers into release/7.23
Sites / deploy (push) Successful in 3m55s
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m57s
Build / Build-and-test-development (pull_request) Successful in 26m51s
Reviewed-on: #485
2026-11-06 12:37:08 +00:00
dc d3f1b2ac0a test(viewbox): select a picker table without walking the tree
Build / Build-and-ng-test (pull_request) Successful in 3m52s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m19s
Build / Build-and-test-development (pull_request) Successful in 26m30s
openTableFromViewboxTree paused 300ms before looking at each rendered node,
which on a library of this size cost ~13.4s of the ~20s a viewbox test takes.
The helper now expands the library and clicks the table's link, retrying the
expansion when the picker re-renders underneath it.

It also waits for the picker's own state before returning: every requested
table listed, no row spinner, and the ~600ms window in which loadData()
re-selects the box that just loaded. Without those, the next step read the
other viewbox's column list and failed on a column it does not have.

Measured on the local stack: the spec runs 2:18-3:15 against 5:22 before, with
all 12 tests passing in both.
2026-11-06 12:10:28 +00:00
dc 7a5286fca8 feat(logs): number the lines of the log, the source and the generated code
Build / Build-and-ng-test (pull_request) Successful in 4m22s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m17s
Build / Build-and-test-development (pull_request) Successful in 33m3s
The SAS Logs modal rendered the log, the source code and the generated code as one text block each, so no line could be referred to and the error, warning and note links had to locate a line by arithmetic. Render one row per line instead: every line carries its number, a wrapped line hangs under the text rather than under the number, and the numbers stay in view when a long log line is scrolled sideways.

The links now scroll to the row itself. The old offset was the measured line height multiplied by the line index, and two things were wrong with it: clientHeight rounds the height to a whole pixel, and the wrapper's padding was not counted. In a 168-line log at the stylesheet's own metrics the early links landed within a few pixels of their line while the later ones did not; at an 18.5px line height, which is what 16px monospace at 1.15 produces, the scroll for line 43 was 10.5px out and for line 127 was 52.5px out, so the highlight sat above the line it named and the drift grew with the line number. A row's own position cannot drift: every link lands its line at the top of the log, less the wrapper's 1px border, and flashes the right row.

The flash is a class on the rows rather than an absolutely positioned overlay, so a message SAS wrapped over several lines flashes every line it runs to.

One row per line costs about 0.25ms in Angular - a binding plus a sanitizer pass each - so rendering a whole log would freeze the modal: 20,000 lines took 5.3s to open. Each pane renders a window of 400 lines with a "Show more" line underneath, a jump renders through to the line it was given before scrolling, and opening is now flat at 165-280ms for 1,000, 5,000 and 20,000 lines.
2026-11-06 11:25:50 +00:00
allan 25ae900f06 Merge pull request 'ci(build): keep the mock tree's own tests out of the Cypress gate' (#482) from ci/skip-cypress-for-mock-tests into release/7.23
Sites / deploy (push) Successful in 3m21s
Build / Build-and-ng-test (pull_request) Successful in 3m59s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m3s
Build / Build-and-test-development (pull_request) Successful in 27m6s
Reviewed-on: #482
2026-11-06 10:32:42 +00:00
allan 7133c97ad4 Merge pull request 'fix(version): fall back to package.json when no tag is reachable' (#484) from fix/483-version-dropdown into release/7.23
Sites / deploy (push) Successful in 3m7s
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Build / Build-and-test-development (pull_request) Canceled after 6m18s
Lighthouse Checks / lighthouse (pull_request) Canceled after 9m9s
Reviewed-on: #484
2026-11-06 10:19:19 +00:00
dc da8e7d343c fix(version): fall back to package.json when no tag is reachable
Build / Build-and-ng-test (pull_request) Successful in 4m21s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m28s
Build / Build-and-test-development (pull_request) Successful in 29m8s
git-describe only sees tags reachable from HEAD. CI checks out a single commit with no tags, so it reports the bare hash and every version in the app reads "v.<hash>" - including the production release builds. The root package.json carries the released version (semantic-release bumps it before the shipped frontend is built), so use it as the tag when git-describe found none, and fill semverString from it for the browser_info diagnostics.
2026-11-06 09:46:31 +00:00
dc 88616c8f62 ci(build): reuse the client npm cache and the Cypress binary in the Cypress job
Build / Build-and-ng-test (pull_request) Successful in 3m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m37s
Build / Build-and-test-development (pull_request) Successful in 32m31s
The job installed the same lockfile the build job had just installed and paid
the downloads again (92s), and npx cypress install fetched its binary on every
run. Both now come from the cache the build job already fills, with the
Cypress binary keyed on the lockfile.
2026-11-06 09:44:42 +00:00
dc 68dc790698 ci(build): keep the mock tree's own tests out of the Cypress gate
Build / Build-and-ng-test (pull_request) Successful in 4m21s
Build / Build-and-test-development (pull_request) Canceled after 8m50s
Lighthouse Checks / lighthouse (pull_request) Canceled after 9m22s
sas/mocks/test/ holds the node:test harnesses that read the payload files, and
the job deploys sas/mocks/sasjs/ and nothing else - so a pull request that
changes only those tests cannot change what the browser is served, yet it
started the Cypress suite. The file list is filtered before the match, so any
other file under sas/mocks/ still counts, a change that touches the tests next
to a service included.

The matcher's cases cover both directions.
2026-11-06 09:30:50 +00:00
allan 8646381044 Merge pull request 'fix(mocks): restore the sas9 separators and convert the sasjs lineage capture' (#479) from fix/463-sas9-getdata-separators into release/7.23
Sites / deploy (push) Successful in 4m12s
Build / Build-and-ng-test (pull_request) Successful in 4m42s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m26s
Build / Build-and-test-development (pull_request) Successful in 27m10s
Reviewed-on: #479
2026-11-06 09:07:21 +00:00
dc caa58771bf fix(mocks): emit the sasjs lineage capture through webout()
Build / Build-and-ng-test (pull_request) Successful in 3m42s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m16s
Build / Build-and-test-development (pull_request) Successful in 29m9s
The capture assigned its body to _webout in one template literal, and that
literal was not JSON: raw line breaks inside the graphviz label and an
unescaped quote in labelloc = "t". fromsas, clickableids, info and flatdata
are members now, written as string literals that keep the label's quotes and
line breaks, with the envelope supplied by the runtime.

sas/mocks/test/sasjs-webout.test.js holds the six services that set their own
content headers, and pins this capture's members. CONTRIBUTING.md lists the
same six.
2026-11-06 09:03:01 +00:00
dc 67631133f9 test(mocks): pin the missing-separator branches and check a delimited drive
Build / Build-and-ng-test (pull_request) Successful in 4m57s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m56s
Build / Build-and-test-development (pull_request) Successful in 32m56s
Each fault shape the tree repairs now has a case that builds it - drive,
OMSOBJ, BUILTIN and DOMAIN - so a heuristic that stops detecting one fails the
suite instead of leaving only the exact-value assertion behind.

A drive letter is path-initial when nothing word-like precedes it, so a path
after <, >, =, [ or a comma is checked too; prose that puts a word before a
colon still is not a drive.
2026-11-06 02:53:11 +00:00
dc f1ba6362cc test(mocks): build the UNC cases from the code point
Build / Build-and-ng-test (pull_request) Successful in 4m17s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m8s
Build / Build-and-test-development (pull_request) Canceled after 15m30s
The two cases added for a path after a tag and after a comma were written as
single backslashes in the source, so each one evaluated to a value with no
separator at all and pinned nothing. The value is now assembled from
String.fromCharCode, with an assertion that it starts with the two separators
the case needs.
2026-11-06 02:33:19 +00:00
dc 755083bb49 test(mocks): fail a service that emits no member, and drop the history from the header
Build / Build-and-ng-test (pull_request) Successful in 6m17s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m30s
Build / Build-and-test-development (pull_request) Canceled after 13m34s
A service whose payload does not arrive as (kind, name, rows) contributed no
members and passed the tree-wide test with nothing checked; the loop now
records the file with no members captured.

The header describes what the values and the test do. The ticket numbers it
carried are gone: AGENTS.md allows a tracker reference only as a link, and the
behaviour reads without one.
2026-11-06 02:13:24 +00:00
dc 0e74ab58a1 test(mocks): pin the tree's scope and name the service that cannot run
Build / Build-and-ng-test (pull_request) Successful in 3m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m1s
Build / Build-and-test-development (pull_request) Successful in 29m25s
A file that does not emit through webout() and is not a listed helper fails a
scope test, so a renamed emitter shrinks the run set loudly instead of leaving
the replay over an empty set. Every webout kind is captured, a service that
needs a global the sandbox lacks names itself in the error, and the header
states the scope the scan covers.
2026-11-06 01:34:26 +00:00
dc c9119101a4 test(mocks): describe the separator rule as it applies to the webout() tree
Build / Build-and-ng-test (pull_request) Successful in 3m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m5s
Build / Build-and-test-development (pull_request) Successful in 29m15s
The header described the captures as live template literals, which this tree
no longer carries; the values now travel through webout() and the same
backslash is one escape away from the same damage.
2026-11-06 00:55:17 +00:00
dc 676cdf6fdd test(mocks): run only the services that emit, and widen the UNC exception
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Build / Build-and-test-development (pull_request) Canceled after 2m39s
Lighthouse Checks / lighthouse (pull_request) Canceled after 8m1s
The replay covers the files that call webout(), so a shared module added to
the tree is skipped instead of failing with a require error, while the
_webout assertion still reads every file - a service that writes its own body
cannot slip past the filter.

A UNC prefix is legal after any character that is not a word character, a
colon or another separator, so a path inside a tag or after a comma keeps its
prefix instead of reading as a doubled separator.
2026-11-06 00:47:13 +00:00
dc 88cc68e2f9 test(mocks): anchor the doubled-separator rule and resolve sibling requires
Build / Build-and-ng-test (pull_request) Successful in 3m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m2s
Build / Build-and-test-development (pull_request) Successful in 26m56s
The whole-tree check treated any two consecutive backslashes as a fault, so a
UNC library path (\\server\share\...) failed on a correct value. A run of two
is now legal only where a UNC path puts it - the start of a value or of a quoted
path - and two separators anywhere else, plus any run of three, are still a
fault. faultsIn gains a case for each direction.

The sandbox require delegated relative requests to this test file's own
require, so a service requiring a sibling module resolved it under
sas/mocks/test. A relative request now resolves from the service file; builtins
and absolute paths delegate unchanged.
2026-11-06 00:15:03 +00:00
dc bafbe201ce fix(mocks): restore the two separators the sas9 getdata recording lost
Build / Build-and-ng-test (pull_request) Successful in 4m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m51s
Build / Build-and-test-development (pull_request) Successful in 29m4s
getdata's ENGINEHOST metadata rows carried a Windows path and an account
name with no separators, which SAS writes and the rest of this tree
already has: the rows read C:DataControllerDC996664mpe_x_test.sas7bdat
and BUILTINAdministrators, against refreshlibinfo's C:\DataController\
DC996664 library and BUILTIN\Administrators owner in the same recording.

Both values now carry single separators.

sas/mocks/test/sas9-separators.test.js replays all 36 files of the tree
under a webout() stub and fails on a doubled separator, or a missing one
in an OMSOBJ URI, a drive path or a BUILTIN/DOMAIN account. It also pins
the two corrected rows and asserts that no service assigns _webout.
2026-11-05 23:18:52 +00:00
dc 109711b9a0 fix(viewer): key the export menu so Handsontable keeps it
Sites / deploy (push) Successful in 3m19s
The CSV / Excel / PDF export submenu never reached the screen. HOT's
ExportFile plugin contributes a context-menu entry under the reserved key
`export_file`, and ContextMenu's ItemsFactory.setPredefinedItems folds the
plugin's entry over the grid's entry at the same key. That folded entry
carries `hidden() { return this.getSettings().exportFile === undefined }`,
and no grid in this app sets `exportFile`, so the whole item - submenu and
all - was filtered out of the menu. Seen on the 7.22.0 build: the viewer's
context menu offers Copy with header, Copy header only and Show labels, and
nothing else, so the client-side PDF export could not be reached at all.

Give the item a key of the app's own, EXPORT_MENU_KEY, and use it at the
three grids that build the menu (viewer, editor, viewboxes). The submenu
keys move with it.

export-menu.spec.ts pins both halves against a real grid, because the
merge happens inside the plugin at menu-build time and no unit test of the
item object can see it: configured under `export_file` the item is absent,
configured under EXPORT_MENU_KEY it is present.
2026-11-05 22:24:45 +00:00
dc 6bf7f9ba0d ci(docs): give the SAS docs deploy the Cloudron password that exists
Sites / deploy (push) Successful in 3m19s
The 7.22 release's sas-docs job failed with surfer's "Run surfer config first,
or provide --server ... --password" message, and its log shows SURFER_PASSWORD
empty - the CODE_DATACONTROLLER_IO secret resolves to nothing. Authenticate
with WEBDOC_DATACONTROLLER_IO, the password the typedoc job in this same
workflow already uses: a Cloudron app password belongs to the user, and the
same user owns both docs apps, so one credential covers both.
2026-11-05 21:29:35 +00:00
dc 205adf0a2b ci: put the remaining workflows on the node version the build uses
build.yaml, lighthouse.yaml and sites.yaml were moved to Node 24.15.0 on
2026-10-28 (d84a1d50); release.yaml, release-assets.yaml and docs.yaml were left
on 24.5.0. The release workflow builds the same client and runs the same Cypress
suite as build.yaml, so it should not be the one place pinning a different
runtime - and docs.yaml builds the frontend for typedoc with it too.
2026-11-05 21:29:23 +00:00
dc f3d1eb11d1 ci(release): attach cypress evidence only on failure
Sites / deploy (push) Successful in 3m17s
The release workflow recorded video for every spec and uploaded
cypress-videos.zip on every run, green or red. Recording eleven specs costs
encode time each run and the recording is only ever opened after a failure, so
run with video=false,screenshotOnRunFailure=true - the same pair build.yaml
already passes - and zip screenshots plus videos only under if: failure().
2026-11-05 21:17:10 +00:00
semantic-release-bot e07246ac68 chore(release): 7.22.0 [skip ci]
# [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](3572d56cfb))
* **build:** build the mock sites with a CLI whose runtime provides webout() ([b1eab98](b1eab98200))
* **build:** publish the changed-path decision and pin the matcher with tests ([ecc8702](ecc87021c3))
* **build:** treat a sas/package.json bump as a Cypress input ([48df795](48df795202))
* **client:** cancel a queued dropdown reposition on destroy ([590b841](590b841f24))
* **client:** drop the synthetic window resize dispatches ([2535baa](2535baa5f7))
* **client:** keep the ARIA pass on the viewbox and multi-dataset grids too ([cb30469](cb30469e79))
* **client:** restrict the table pass to Handsontable's own tables, add the viewer fallback ([46c6aec](46c6aec37a))
* **client:** scope the ARIA pass to each grid and drive it from the render hooks ([bf1e7d6](bf1e7d6b15))
* **client:** stop synthesising aria-labels; name the filter modal's submit in the test ([32d3d49](32d3d49b59))
* **client:** strip only this grid's own wrapper, and pin the re-strip after a data load ([f282d1a](f282d1a44b))
* **deploy:** ACTION cannot seed by default, and the probe stops overwriting ([e84cfaa](e84cfaaabd))
* **deploy:** ask for the admin group before the staging directory on SASjs ([5c64691](5c646913e4))
* **deploy:** await nothing, and encode the values in the makedata URL ([d67a0e5](d67a0e5d5f))
* **deploy:** drop the deprecated service-pack deploy from the setup screen ([59e09e2](59e09e26a5))
* **deploy:** gate the browse actions on the settings file ([5eb8492](5eb8492742))
* **deploy:** make the folder picker work on both estates ([fb38309](fb38309e98))
* **deploy:** match the documented SYSSCPL set and stop guessing at the cap ([5f338ff](5f338ff437))
* **deploy:** read a URL-supplied ACTION before defaulting, and show locked folders ([addb4a5](addb4a56ea))
* **deploy:** stop the makedata poll outliving its screen, and align the release gates ([c808c41](c808c41333)), closes [#407](#407)
* **deploy:** unclamp the folder picker row and probe in the chosen context ([1330925](133092502c))
* **deps:** bump @sasjs/core to 7.0.1 so special missings can be restored ([3d2eefd](3d2eefdd7b)), closes [sasjs/core#499](sasjs/core#499)
* **deps:** bump @sasjs/core to 7.1.3 so a filter abort names the clause ([c7c1815](c7c181518c))
* **editor:** a container resize must not clear an active sort ([5bd7b5d](5bd7b5d724)), closes [#246](#246) [#246](#246)
* **editor:** drop the stale case hint, and narrow the strip to our own form ([bd2739c](bd2739cde4))
* **editor:** one predicate for the CASE marker, and no stacking in the tooltip ([18b0546](18b0546dcb))
* **embed:** a rejected mapping must not block the page ([d845b9e](d845b9e9b7)), closes [dc/dc#404](#404)
* **embed:** read a month-name date from VA as a date, not a character value ([30cefcf](30cefcf2dc))
* **getgroupmembers:** widen membername to $256 for long names ([196d6c9](196d6c931d))
* **lint:** enable noSingleAsteriskComments and noUnusedLibnames ([9071801](9071801ac5)), closes [#376](#376)
* **lint:** make noBannedStrings an error and clear its 13 findings ([0b85f7b](0b85f7bf1e)), closes [#376](#376)
* **lint:** make noUndeclaredMacros an error, and fix what it found ([af28a79](af28a79bd5)), closes [#376](#376)
* **lint:** make noUnusedMacros an error and clear its 54 findings ([c8c0858](c8c0858295)), closes [#376](#376)
* **lint:** rename the short and mixed-case helper macros, and clear the last warnings ([7da67eb](7da67eb17b)), closes [#376](#376)
* **makedata:** let the browse actions live as long as the service does ([912fae4](912fae448b))
* **mocks:** 0 is a closed TX_TO, a libref boundary, and the union of the row keys ([67d9cfd](67d9cfd1bd))
* **mocks:** abort like mp_abort, lock like mp_lockanytable, derive saslibs ([ca60208](ca602082ff))
* **mocks:** answer the deploy screen's DIRLIST and WRITECHECK actions ([e1c3c6e](e1c3c6e872))
* **mocks:** derive startupservice's maps and timezone instead of hardcoding ([87fd46c](87fd46c825))
* **mocks:** fold the audit window, and append MPE_AUDIT under its lock ([5ecc7be](5ecc7bebec))
* **mocks:** keep the OMS and path separators the captures lost ([7d9d466](7d9d466c3e))
* **mocks:** one sasDatetimeNow, a documented TX_TO=0, and a shaped fallback export ([e53c50d](e53c50d4e2))
* **mocks:** read hook columns in either case, and guard duplicates first ([349a2f3](349a2f314c))
* **mocks:** read the metadata member from the response, not the function's arity ([09e9f19](09e9f192c5))
* **mocks:** refuse a runtime whose webout() cannot carry column metadata ([482435f](482435f318))
* **mocks:** resolve the audit table once, record lower-case md5, and report a failed audit write ([1a9f594](1a9f59478b))
* **mocks:** run approval hooks, as mpe_runhook does ([f0208bb](f0208bb0b0))
* **mocks:** separate a body read failure from a missing showmeta floor ([744c89b](744c89b919))
* **mpe_abortmessage:** decide once whether the payload was escaped ([af89126](af89126acf))
* **mpe_abortmessage:** read program and state by name, and trim the prefix ([68b89ca](68b89caf63))
* **mpe_assert_abort:** anchor the cut on the whole escaped separator ([e74a7ba](e74a7ba115))
* **mpe_assert_abort:** compare the hook's message, not the log extract ([8b426c2](8b426c27fd))
* **mpe_assert_abort:** compare the unescaped message, cut at the last marker ([e68d221](e68d221354))
* **mpe_assert_abort:** cut at the separator, not the last marker ([4c7e869](4c7e869369))
* **mpe_assert_abort:** name the separator and tolerate a carriage return ([7b09ade](7b09ade97c))
* **mpe_assert_abort:** size the capture buffer for the whole message ([5d32de9](5d32de9df9))
* **mpe_assert_abort:** strip the separator tail, not every trailing escape ([b29bc1e](b29bc1e7ae))
* **mpe_execute:** assemble the abort text in a macro that can be tested ([e2e9103](e2e91039cb))
* **mpe_execute:** keep a child abort message intact when reporting it ([ebfd395](ebfd39582f))
* **mpe_execute:** keep the child message in the fallback, and test the doubled separator ([11587e2](11587e2f24))
* **mpe_execute:** make the abort-text fallback reachable ([24b8cbe](24b8cbe9dc))
* **mpe_execute:** mask a child abort message before it aborts the caller ([20dbf00](20dbf00fb0))
* **mpe_execute:** one symbol for the abort text, and no values in the call ([700b906](700b906d69))
* **multi-dataset:** a sheet that failed to extract is not a clean finish ([3129648](312964864e))
* **multi-dataset:** show the file size and loading stage while the file loads ([45d6c02](45d6c025c9)), closes [#121](#121)
* **report:** treat a blank PDF footer parameter as its default ([0f729d7](0f729d7101))
* **test:** let the dirlist and writecheck results reach the caller on Viya ([ed678f0](ed678f0b46))
* **test:** run metanav.cy.ts in CI and open a metadata object ([02a8590](02a8590310))
* **va-host:** bound the early replay even when the frame is not postable ([f6d5a04](f6d5a0495e))
* **va-host:** deliver in load mode, honour dataType, and survive odd prompt names ([7d4155d](7d4155dc6c))
* **va-host:** deliver the load-mount message once, and document report.json from the file ([90784b3](90784b342f))
* **va-host:** keep the deploy password off argv, and verify the app is served ([7c63a5c](7c63a5c93f))
* **va-host:** post to the frame's own origin, and key the replay to the document ([9cec292](9cec292681))
* **viewer:** clamp over-tall PDF rows, always emit an otherwise-empty page ([10229d5](10229d5efe))
* **viewer:** defer the blob URL revoke, reject a selection inside skipped columns ([b5bb704](b5bb704a4f))
* **viewer:** keep the PDF export working where hiddenColumns is not enabled ([f84d4ca](f84d4ca7af))
* **viewer:** review findings - CSV skipped column, PDF row cap before reading ([2c4be5e](2c4be5e94b))

### Features

* **admin:** find and test a writeable location during deploy ([442c595](442c595c2b))
* **deploy:** pick and validate the DC location from the deploy screens ([6eeab2e](6eeab2ed16))
* **editor:** convert CASE rule values on entry ([c4eef49](c4eef498b2))
* **logs:** read the logs modal with the shared log parser ([aabc578](aabc578d5e))
* **mocks:** add the control-table services the screens call ([a1615ee](a1615ee8a7))
* **mocks:** build every JS stored program's response with webout() ([ce6bb5d](ce6bb5d4cd))
* **mocks:** emit the lineage, usernav and libinfo responses through webout() ([1f54fad](1f54fad3fe))
* **mocks:** emit the metanav responses through webout() ([e1d877f](e1d877f21d))
* **mocks:** emit the sas9 services through webout() ([7be3f84](7be3f84073)), closes [#461](#461)
* **mocks:** restore a load, and record the diffs it needs ([1a60c68](1a60c689f4))
* **mocks:** use the server's webout() in the JS stored programs ([ccb67ed](ccb67ed5a0))
* **va-host:** emulate a VA report that embeds DC, as a deployable AppStream app ([2185db9](2185db911d))
* **viewer:** download a server-side PDF report ([67ca129](67ca129c4d))
* **viewer:** export the grid as PDF from the context menu ([e58e735](e58e735610))

### Reverts

* keep the helpers nested inside makedata_body ([4424171](4424171b2f))
2026-11-05 21:11:44 +00:00
allan 39e8ffdbdd Merge pull request 'feat: Release/7.22' (#407) from release/7.22 into main
Release / Build-production-and-ng-test (push) Successful in 4m25s
Release / Build-and-test-development (push) Successful in 21m8s
Release / release (push) Successful in 4m37s
Reviewed-on: #407
2026-11-05 20:42:41 +00:00
allan 6f7f2d189f Merge pull request 'fix(deploy): drop the browse gate, and ask for the admin group before the directory' (#478) from fix/7.22-browse-window-and-order into release/7.22
Sites / deploy (push) Successful in 3m6s
Build / Build-and-ng-test (pull_request) Successful in 3m46s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m4s
Build / Build-and-test-development (pull_request) Successful in 29m18s
Reviewed-on: #478
2026-11-05 18:54:47 +00:00
dc 5c646913e4 fix(deploy): ask for the admin group before the staging directory on SASjs
Build / Build-and-ng-test (pull_request) Successful in 4m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m31s
Build / Build-and-test-development (pull_request) Successful in 33m3s
The SASjs Server / SAS9 screen put the DC Staging Directory first and the admin
group second, the reverse of the Viya screen, so the operator was asked to pick
a location before being told who administers DC. It now asks for the admin group
first and the directory last, with the copy reordered to match.

A rendered-order spec covers it: it reads the labels off the rendered template
and fails if the directory comes first again.
2026-11-05 18:21:54 +00:00
dc 912fae448b fix(makedata): let the browse actions live as long as the service does
DIRLIST and WRITECHECK were refused once the appLoc had a settings.sas, on the
argument that a failed run could leave that file behind. That is what makes the
gate wrong: a failed run is exactly when the deploy screen still needs to list
and probe directories, and any appLoc configured earlier refuses them for good.
The window needs no separate clock - a successful run deletes this program, so
once DC is configured there is nothing left here to call.

Removed from both variants (server checks the drive over HTTP, Viya checks the
filesystem; neither is needed), and the two tests that asserted the refusal go
with it. The macros themselves are covered by mpe_dirlist.test.sas and
mpe_writecheck.test.sas.

Seen on sas.4gl.io: a deployment to /Public/app/dc477 answered BROWSE with
"DIRLIST and WRITECHECK are only available until DC is configured".
2026-11-05 18:21:54 +00:00
allan eef91ce989 Merge pull request 'fix(7.22): clear the makedata poll on destroy, and close the release-gate findings' (#477) from fix/7.22-release-followups into release/7.22
Sites / deploy (push) Successful in 3m6s
Build / Build-and-ng-test (pull_request) Successful in 6m18s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m47s
Build / Build-and-test-development (pull_request) Successful in 27m24s
Reviewed-on: #477
2026-11-05 17:58:28 +00:00
42 changed files with 5315 additions and 3324 deletions

No files matched your search

+5 -1
View File
@@ -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 \ cd sas/mocks && sasjs request services/admin/makedata -t server-ci \
-d deploy/makedata.json -o ./makedata_out.json -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. 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.
+14 -3
View File
@@ -30,6 +30,13 @@
# is not in the set - the job installs that CLI globally by version string, # is not in the set - the job installs that CLI globally by version string,
# so the lockfile cannot change what it resolves. # 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 # Usage: BASE_REF=<base branch> bash .gitea/scripts/cypress-changes.sh
set -uo pipefail 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 # --match < file-list reads newline-separated paths on stdin and prints
# true/false, so the matcher can be exercised with fixed lists and no # true/false, so the matcher can be exercised with fixed lists and no
# repository. It is what the test above drives. # 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 [ "${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 exit 0
fi fi
@@ -64,12 +75,12 @@ if ! CHANGED="$(git diff --name-only "refs/remotes/origin/${BASE}" HEAD)"; then
exit 0 exit 0
fi fi
if grep -qE "^(${PATHS})" <<<"$CHANGED"; then if served_changes <<<"$CHANGED" | grep -qE "^(${PATHS})"; then
echo "client=true" >>"${GITHUB_OUTPUT:-/dev/null}" echo "client=true" >>"${GITHUB_OUTPUT:-/dev/null}"
echo "the Cypress job's inputs changed:" echo "the Cypress job's inputs changed:"
else else
echo "client=false" >>"${GITHUB_OUTPUT:-/dev/null}" 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:" echo "Skipping the Cypress suite - nothing it exercises changed:"
fi fi
printf '%s\n' "$CHANGED" printf '%s\n' "$CHANGED"
+10 -1
View File
@@ -26,7 +26,8 @@ check() { # <expected> <path>...
# deploys, the CLI pin it installs, the scripts that decide what it runs, and # deploys, the CLI pin it installs, the scripts that decide what it runs, and
# the dependency/workflow files that change the first two. # the dependency/workflow files that change the first two.
check true client/src/app/app.component.ts 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 sas/package.json
check true .gitea/scripts/cypress-specs.sh check true .gitea/scripts/cypress-specs.sh
check true package-lock.json check true package-lock.json
@@ -43,6 +44,14 @@ check false .gitea/workflows/lighthouse.yaml
check false clientx/foo.ts check false clientx/foo.ts
check false sas/package-lock.json 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 if [ "$fails" -ne 0 ]; then
echo "cypress-changes matcher: $fails case(s) failed" echo "cypress-changes matcher: $fails case(s) failed"
exit 1 exit 1
+31 -4
View File
@@ -373,6 +373,28 @@ jobs:
env: env:
CYPRESS_CREDS: ${{ secrets.CYPRESS_CREDS }} 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 - name: Install dependencies
if: steps.changes.outputs.client != 'false' if: steps.changes.outputs.client != 'false'
env: env:
@@ -464,12 +486,17 @@ jobs:
# deploy and makedata start from a clean appLoc # deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc" curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
sasjs cbd -t server-ci sasjs cbd -t server-ci
# Seed the DC database (mock data files on the drive). # Seed the DC database (mock data files on the drive). The payload
# makedata replies with HTML (it is normally called as a URL redirect), # asks for OUTFMT=JSON, so the CLI parses the response and a failed
# which the CLI reports as "invalid Json string" - ignore that. # 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), # NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs). # 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 - name: Prepare and run frontend and cypress
if: steps.changes.outputs.client != 'false' if: steps.changes.outputs.client != 'false'
+2 -2
View File
@@ -9,7 +9,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
NODE_VERSION: '24.5.0' NODE_VERSION: '24.15.0'
jobs: jobs:
typedoc: typedoc:
@@ -118,7 +118,7 @@ jobs:
# marketing site, which is why it must not be the target. # marketing site, which is why it must not be the target.
- name: Deploy SAS docs - name: Deploy SAS docs
env: env:
SURFER_PASSWORD: ${{ secrets.CODE_DATACONTROLLER_IO }} SURFER_PASSWORD: ${{ secrets.WEBDOC_DATACONTROLLER_IO }}
REF_NAME: ${{ gitea.ref_name }} REF_NAME: ${{ gitea.ref_name }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
run: | run: |
+1 -1
View File
@@ -17,7 +17,7 @@ on:
default: '' default: ''
env: env:
NODE_VERSION: '24.5.0' NODE_VERSION: '24.15.0'
permissions: permissions:
contents: write contents: write
+31 -14
View File
@@ -8,7 +8,7 @@ on:
env: env:
# the server the mocked services and app tests run against - bump deliberately # the server the mocked services and app tests run against - bump deliberately
SASJS_SERVER_VERSION: '3.14.0' SASJS_SERVER_VERSION: '3.14.0'
NODE_VERSION: '24.5.0' NODE_VERSION: '24.15.0'
jobs: jobs:
Build-production-and-ng-test: Build-production-and-ng-test:
@@ -209,12 +209,17 @@ jobs:
# deploy and makedata start from a clean appLoc # deploy and makedata start from a clean appLoc
curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc" curl -sS -X DELETE "http://localhost:5000/SASjsApi/drive/folder/?_folderPath=/Public/app/dc"
sasjs cbd -t server-ci sasjs cbd -t server-ci
# Seed the DC database (mock data files on the drive). # Seed the DC database (mock data files on the drive). The payload
# makedata replies with HTML (it is normally called as a URL redirect), # asks for OUTFMT=JSON, so the CLI parses the response and a failed
# which the CLI reports as "invalid Json string" - ignore that. # 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), # NOTE: -d paths resolve from the sasjs project root (sas/mocks),
# not the cwd (sas/mocks/sasjs). # 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 - name: Prepare and run frontend and cypress
env: env:
@@ -237,20 +242,32 @@ jobs:
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
cat ./cypress.config.ts cat ./cypress.config.ts
# Start frontend and run cypress # 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 - name: Zip Cypress failure evidence
if: always() # Only on a failure: a green run has nothing to look at.
if: failure()
run: | run: |
mkdir -p ./client/cypress/videos mkdir -p ./client/cypress/screenshots ./client/cypress/videos
zip -r cypress-videos ./client/cypress/videos zip -r cypress-artifacts.zip ./client/cypress/screenshots ./client/cypress/videos
- name: Add cypress videos artifacts - name: Add cypress failure evidence as an artifact
if: always() 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 uses: actions/upload-artifact@v3
with: with:
name: cypress-videos.zip name: cypress-artifacts.zip
path: cypress-videos.zip path: cypress-artifacts.zip
- name: Remove registry config - name: Remove registry config
# written earlier in this job; nothing deletes it on the way out # written earlier in this job; nothing deletes it on the way out
+7 -5
View File
@@ -132,11 +132,13 @@ jobs:
if: ${{ startsWith(gitea.ref, 'refs/tags/') }} if: ${{ startsWith(gitea.ref, 'refs/tags/') }}
run: | run: |
cd sas/mocks/sasjs cd sas/mocks/sasjs
# makedata replies with HTML (it is normally called as a URL redirect), # the payload asks for OUTFMT=JSON, so a failed seed fails this step
# which the CLI reports as "invalid Json string" - that exit is # rather than being published as an empty site (the drive is checked
# expected. A real failure must not be published as an empty site, so # as well, before the snapshot is taken)
# 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
../../node_modules/.bin/sasjs request services/admin/makedata -t server-ci -d deploy/makedata.json -o ./makedata_out.json || true # 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" DATA_DIR="../../../sasjs_root/drive/files$APP_LOC/data"
SEEDED=$(find "$DATA_DIR" -name '*.json' 2>/dev/null | wc -l) SEEDED=$(find "$DATA_DIR" -name '*.json' 2>/dev/null | wc -l)
+106
View File
@@ -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) # [7.21.0](https://git.datacontroller.io/dc/dc/compare/v7.20.0...v7.21.0) (2026-10-29)
+11 -2
View File
@@ -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`. 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/` After deploy, the streamed app is available at: `http://localhost:5000/AppStream/clickme/`
### 3. Run the Angular dev server (optional, for hot reload) ### 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 ### 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. 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.
+1 -1
View File
@@ -12,7 +12,7 @@ const serverType = Cypress.env('serverType')
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`) const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
// Fixture: MPE_X_SEARCH, a 1000-row demo table built by the mock data builder // 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: // count below can be derived by hand:
// - 1000 rows, PK 1001-2000, columns in demo order: PRIMARY_KEY_FIELD, // - 1000 rows, PK 1001-2000, columns in demo order: PRIMARY_KEY_FIELD,
// SITE_NAME, VESSEL, SAMPLE_COUNT, DEPTH_M, SPECIES, CRUISE_DATE, // SITE_NAME, VESSEL, SAMPLE_COUNT, DEPTH_M, SPECIES, CRUISE_DATE,
+66 -144
View File
@@ -45,20 +45,10 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => { generateKeys(keyData, (keysGen: any) => {
keys = keysGen keys = keysGen
cy.wait(2000) gotoLicensingUpdate()
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
isLicensingPage((result: boolean) => { cy.then(() => {
if (result) {
inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
cy.wait(2000)
}
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(10000)
}
visitPage('home') visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', { cy.get('.nav-tree clr-tree > clr-tree-node', {
@@ -86,18 +76,9 @@ context('licensing tests: ', function () {
keys = keysGen keys = keysGen
console.log('keys', keys) console.log('keys', keys)
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => { updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
verifyLicensingWarning('This license key will expire in ', () => { verifyLicensingWarning('This license key will expire in ', () => {
done() done()
}) })
@@ -120,19 +101,9 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => { generateKeys(keyData, (keysGen: any) => {
keys = keysGen keys = keysGen
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => { updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
verifyLicensingPage( verifyLicensingPage(
'Licence key is expired - please contact', 'Licence key is expired - please contact',
(success: boolean) => { (success: boolean) => {
@@ -166,19 +137,9 @@ context('licensing tests: ', function () {
keys = keysGen keys = keysGen
keys.activationKey = 'invalid' + keys.activationKey keys.activationKey = 'invalid' + keys.activationKey
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => { updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
verifyLicensingPage( verifyLicensingPage(
'Licence key is invalid - please contact', 'Licence key is invalid - please contact',
(success: boolean) => { (success: boolean) => {
@@ -212,19 +173,9 @@ context('licensing tests: ', function () {
keys = keysGen keys = keysGen
keys.activationKey = keys.activationKey keys.activationKey = keys.activationKey
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
cy.wait(2000)
updateLicenseKeyQuick(keysGen, () => { updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
verifyLicensingPage( verifyLicensingPage(
'SYSSITE (below) is not found', 'SYSSITE (below) is not found',
(success: boolean) => { (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) => { it('6 | pasting an HTTP-format key (identical licence/activation text) on this secure connection warns before applying', (done) => {
visitPage('licensing/update') 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 // Defaults to the combined single-field form - switch to the legacy
// two-field layout, since this test targets that path specifically. // two-field layout, since this test targets that path specifically.
@@ -287,25 +240,17 @@ context('licensing tests: ', function () {
} }
generateKeys(keyData, (keysGen: any) => { generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on // Navigate there explicitly rather than only acting when already on
// the licensing page - earlier tests may have left the app on it (a // 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 // 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. // pass either way rather than silently no-op when it's the latter.
isLicensingPage((result: boolean) => { cy.then(() => {
const proceed = () => const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then( generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => { (combinedKey) => {
inputCombinedKeyPage(combinedKey) inputCombinedKeyPage(combinedKey)
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => { acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home') visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', { cy.get('.nav-tree clr-tree > clr-tree-node', {
@@ -317,10 +262,7 @@ context('licensing tests: ', function () {
} }
) )
if (!result) { gotoLicensingUpdate()
visitPage('licensing/update')
cy.wait(2000)
}
// proceed() generates the combined key asynchronously, then // proceed() generates the combined key asynchronously, then
// enqueues the actual page commands only once its promise // enqueues the actual page commands only once its promise
@@ -344,15 +286,15 @@ context('licensing tests: ', function () {
} }
generateKeys(keyData, (keysGen: any) => { generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on // Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why. // the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => { cy.then(() => {
const proceed = () => const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then( generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => { (combinedKey) => {
cy.get('button').contains('Paste licence').click() cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('button') cy.get('button')
.contains('Paste as two separate keys instead') .contains('Paste as two separate keys instead')
.click() .click()
@@ -375,13 +317,7 @@ context('licensing tests: ', function () {
cy.get('button.apply-keys').click() cy.get('button.apply-keys').click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => { acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home') visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', { 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 // See test 7's own comment for why proceed() is bridged through
// cy.then() rather than called as a plain statement. // cy.then() rather than called as a plain statement.
if (!result) { gotoLicensingUpdate()
visitPage('licensing/update')
cy.wait(2000)
}
cy.then(() => proceed()) cy.then(() => proceed())
}) })
}) })
@@ -414,11 +347,9 @@ context('licensing tests: ', function () {
} }
generateKeys(keyData, (keysGen: any) => { generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on // Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why. // the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => { cy.then(() => {
const proceed = () => const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then( generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => { (combinedKey) => {
@@ -432,13 +363,7 @@ context('licensing tests: ', function () {
timeout: longerCommandTimeout timeout: longerCommandTimeout
}).click() }).click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => { acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home') visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', { 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 // See test 7's own comment for why proceed() is bridged through
// cy.then() rather than called as a plain statement. // cy.then() rather than called as a plain statement.
if (!result) { gotoLicensingUpdate()
visitPage('licensing/update')
cy.wait(2000)
}
cy.then(() => proceed()) cy.then(() => proceed())
}) })
}) })
@@ -476,15 +398,15 @@ context('licensing tests: ', function () {
} }
generateKeys(keyData, (keysGen: any) => { generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on // Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why. // the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => { cy.then(() => {
const proceed = () => const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then( generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => { (combinedKey) => {
cy.get('button').contains('Paste licence').click() cy.contains('button', 'Paste licence', {
timeout: longerCommandTimeout
}).click()
cy.get('.combined-key-form textarea', { cy.get('.combined-key-form textarea', {
timeout: longerCommandTimeout timeout: longerCommandTimeout
@@ -522,12 +444,8 @@ context('licensing tests: ', function () {
// See test 7's own comment for why this is chained via .then() // See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement. // rather than called as the next plain statement.
if (!result) { gotoLicensingUpdate()
visitPage('licensing/update') cy.then(() => proceed())
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
}) })
}) })
}) })
@@ -550,22 +468,10 @@ context('licensing tests: ', function () {
} }
generateKeys(keyData, (keysGen: any) => { generateKeys(keyData, (keysGen: any) => {
generateKeys(keyData2, (keysGen2: any) => { generateKeys(keyData2, (keysGen2: any) => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => { updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen2, () => { updateLicenseKeyQuick(keysGen2, () => {
cy.wait(2000)
const random = Cypress._.random(0, 1000) const random = Cypress._.random(0, 1000)
const newUser = { const newUser = {
username: `randomusername${random}notregistered`, username: `randomusername${random}notregistered`,
@@ -583,8 +489,6 @@ context('licensing tests: ', function () {
visitPage('home') visitPage('home')
cy.wait(2000)
verifyLicensingPage( verifyLicensingPage(
'The registered number of users reached the limit specified for your licence.', 'The registered number of users reached the limit specified for your licence.',
(success: boolean) => { (success: boolean) => {
@@ -620,17 +524,9 @@ context('licensing tests: ', function () {
generateKeys(keyData, (keysGen: any) => { generateKeys(keyData, (keysGen: any) => {
generateKeys(keyData2, (keysGen2: any) => { generateKeys(keyData2, (keysGen2: any) => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
updateLicenseKeyQuick(keysGen, () => { updateLicenseKeyQuick(keysGen, () => {
cy.wait(2000)
acceptTermsIfPresented((result: boolean) => { acceptTermsIfPresented((result: boolean) => {
if (result) {
cy.wait(20000)
}
const random = Cypress._.random(0, 1000) const random = Cypress._.random(0, 1000)
const newUser = { const newUser = {
username: `randomusername${random}`, username: `randomusername${random}`,
@@ -641,7 +537,6 @@ context('licensing tests: ', function () {
{ deleteAll: true, keep: username, newUsers: [newUser] }, { deleteAll: true, keep: username, newUsers: [newUser] },
() => { () => {
updateLicenseKeyQuick(keysGen2, () => { updateLicenseKeyQuick(keysGen2, () => {
cy.wait(2000)
verifyLicensingWarning( verifyLicensingWarning(
'The registered number of users exceeds the limit specified for your license.', '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) => { const logout = (callback?: any) => {
cy.get('.header-actions .dropdown-toggle') cy.get('.header-actions .dropdown-toggle')
.click() .click()
@@ -681,6 +597,9 @@ const acceptTermsIfPresented = (callback?: any) => {
.then(() => { .then(() => {
cy.get('#checkbox1') cy.get('#checkbox1')
.click() .click()
.then(() => {
waitForAppSettled()
})
.then(() => { .then(() => {
if (callback) callback(true) if (callback) callback(true)
}) })
@@ -700,25 +619,26 @@ const isLicensingPage = (callback: any) => {
} }
const verifyLicensingPage = (text: string, 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) cy.get('.key-error', { timeout: longerCommandTimeout })
isLicensingPage((result: boolean) => { .should('contain', text)
if (result) { .then(() => {
cy.get('.key-error') callback(true)
.should('contain', text) })
.then((treeNodes: any) => {
callback(true)
})
}
})
} }
const verifyLicensingWarning = (text: string, callback: any) => { const verifyLicensingWarning = (text: string, callback: any) => {
visitPage('home') visitPage('home')
cy.wait(1000) waitForAppSettled()
cy.get("div[role='alert'] .alert-text") cy.get("div[role='alert'] .alert-text", { timeout: longerCommandTimeout })
.invoke('text') .invoke('text')
.should('contain', text) .should('contain', text)
.then(() => { .then(() => {
@@ -727,7 +647,9 @@ const verifyLicensingWarning = (text: string, callback: any) => {
} }
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => { 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 // Defaults to the combined single-field form - this helper exercises the
// two-part path specifically, so switch to it first. // two-part path specifically, so switch to it first.
@@ -742,10 +664,13 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
.trigger('input') .trigger('input')
.should('not.be.undefined') .should('not.be.undefined')
cy.get('button.apply-keys').click() cy.get('button.apply-keys').click()
waitForAppSettled()
} }
const inputCombinedKeyPage = (combinedKey: string) => { 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, // Combined is the default paste format - no toggle click needed here,
// unlike inputLicenseKeyPage's legacy two-field path. // unlike inputLicenseKeyPage's legacy two-field path.
@@ -815,7 +740,6 @@ const updateUsersTable = (options: any, callback?: any) => {
// .type('{enter}') // .type('{enter}')
// }) // })
submitTable(() => { submitTable(() => {
cy.wait(2000)
approveTable(callback) approveTable(callback)
}) })
}) })
@@ -838,7 +762,6 @@ const changeLicenseKeyTable = (keys: any, callback?: any) => {
], ],
() => { () => {
submitTable(() => { submitTable(() => {
cy.wait(2000)
approveTable(() => { approveTable(() => {
cy.reload() cy.reload()
if (callback) callback() if (callback) callback()
@@ -853,7 +776,6 @@ const updateLicenseKeyQuick = (keys: any, callback: any) => {
isLicensingPage((result: boolean) => { isLicensingPage((result: boolean) => {
if (!result) { if (!result) {
visitPage('licensing/update') visitPage('licensing/update')
cy.wait(2000)
} }
inputLicenseKeyPage(keys.licenseKey, keys.activationKey) inputLicenseKeyPage(keys.licenseKey, keys.activationKey)
+118 -39
View File
@@ -793,11 +793,47 @@ const openViewViewboxes = () => {
// cy.contains retries until a matching node appears/updates - the open // cy.contains retries until a matching node appears/updates - the open
// viewbox's own list entry can render before its table-name text has // 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. // actually loaded, so a one-shot .then() lookup here would race it.
const openViewboxConfig = (viewbox_tablename: string) => { const openViewboxConfig = (viewbox_tablename: string, attempt = 1) => {
cy.contains('.open-viewbox', viewbox_tablename, { const target = viewbox_tablename.toLowerCase()
matchCase: false, const heading = () =>
timeout: longerCommandTimeout cy.contains('p', 'Configuration (', { timeout: longerCommandTimeout })
}).click()
// "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) => { const openTableFromTree = (libNameIncludes: string, tablename: string) => {
@@ -941,47 +977,90 @@ const setFilterWithValue = (
if (callback) callback() 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 = ( const openTableFromViewboxTree = (
libNameIncludes: string, libNameIncludes: string,
tablenames: string[] tablenames: string[]
) => { ) => {
cy.get('.add-new clr-tree > clr-tree-node', { // The argument is a fragment of the library's own name; escaping it keeps a
timeout: longerCommandTimeout // dot or a bracket in that name from matching more than the node meant.
}).then((treeNodes: any) => { const libraryName = new RegExp(
let viyaLib libNameIncludes.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'i'
)
for (let node of treeNodes) { const libraryNode = () =>
if (node.innerText.toLowerCase().includes(libNameIncludes)) { cy.contains('.add-new clr-tree > clr-tree-node', libraryName, {
viyaLib = node timeout: longerCommandTimeout
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 pause = (ms: number) => { // Scoped to the matched library: the same table name can exist under another
return new Promise((resolve, reject) => { // library that a previous step left expanded.
setTimeout(() => { const tableLinks = ($body: JQuery) =>
resolve(null) $body
}, ms) .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) => { const visitPage = (url: string) => {
@@ -11,23 +11,6 @@
To configure Data Controller for SAS©, please provide the following details: To configure Data Controller for SAS©, please provide the following details:
</p> </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"> <p class="m-0 mt-10">
Below are the list of groups to which you belong. The group you select will 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 become the Data Controller Admin Group, and everyone in it will have
@@ -45,6 +28,23 @@
</select> </select>
</clr-select-container> </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"> <p class="m-0 mt-10">
Now, create the target directory and deploy the control library using the Now, create the target directory and deploy the control library using the
admin group selected above: admin group selected above:
@@ -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 { 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' import { SasjsConfiguratorComponent } from './sasjs-configurator.component'
/** /**
@@ -148,3 +154,78 @@ describe('SasjsConfiguratorComponent directory default', () => {
expect(await directoryFor('DOCKER')).toBe('/tmp/DataController') expect(await directoryFor('DOCKER')).toBe('/tmp/DataController')
}) })
}) })
/**
* The rendered order. DC on a SASjs Server or SAS9 estate is configured by the
* admin group selected here, and the staging directory is the last question
* because it is the one the picker has to prove - the same order the Viya
* screen asks them in. A template edit that put the directory back on top
* would compile and deploy fine and ask the operator to choose a location
* before telling DC who administers it.
*/
describe('SasjsConfiguratorComponent field order', () => {
@Component({
selector: 'app-sasjs-configurator-host',
template: '<app-sasjs-configurator></app-sasjs-configurator>',
standalone: false
})
class HostComponent {}
let fixture: ComponentFixture<HostComponent>
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
SasjsConfiguratorComponent,
HostComponent,
DcLocationComponent
],
imports: [FormsModule],
providers: [
{
provide: SasService,
useValue: {
getExecutionPath: () => '/SASStoredProcess/do',
request: () =>
Promise.resolve({ adapterResponse: { SYSSCPL: 'Linux' } }),
getSasjsConfig: () => ({}),
sasjsMakedataChecking: () => Promise.resolve(false),
getUserGroups: () => ({ subscribe: () => undefined }),
logout: () => undefined
}
},
{
provide: SasjsService,
useValue: {
getServerInfo: () => ({ subscribe: () => undefined })
}
}
],
schemas: [NO_ERRORS_SCHEMA]
})
fixture = TestBed.createComponent(HostComponent)
fixture.detectChanges()
})
afterEach(() => {
TestBed.resetTestingModule()
})
it('asks for the admin group before the staging directory', () => {
const labels = Array.from(
fixture.nativeElement.querySelectorAll('.clr-control-label')
).map((label: unknown) => ((label as HTMLElement).textContent || '').trim())
const admin = labels.findIndex((l) =>
l.toLowerCase().includes('admin group')
)
const directory = labels.findIndex((l) =>
l.toLowerCase().includes('staging directory')
)
expect(admin).toBeGreaterThanOrEqual(0)
expect(directory).toBeGreaterThanOrEqual(0)
expect(admin).toBeLessThan(directory)
})
})
+8 -2
View File
@@ -27,7 +27,10 @@ import { CellValidationSource } from '../models/CellValidationSource'
import { FileUploader } from '../models/FileUploader.class' import { FileUploader } from '../models/FileUploader.class'
import { FilterGroup, FilterQuery } from '../models/FilterQuery' import { FilterGroup, FilterQuery } from '../models/FilterQuery'
import { HotTableInterface } from '../models/HotTable.interface' 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 { import {
$DataFormats, $DataFormats,
DSMeta, DSMeta,
@@ -421,7 +424,10 @@ export class EditorComponent
}, },
// Client-side export of the current grid view (or selection). // Client-side export of the current grid view (or selection).
// skipLeadingCols=1 drops the `Delete?` housekeeping column (col 0). // 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: { sp1: {
name: '---------', name: '---------',
hidden: () => this.hotTable.readOnly === true 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, exportGrid,
isRangeEmpty, isRangeEmpty,
getExportRange, getExportRange,
buildExportMenuItem buildExportMenuItem,
EXPORT_MENU_KEY
} from './hot-export.util' } from './hot-export.util'
/** /**
@@ -203,9 +204,9 @@ describe('hot-export grid extraction', () => {
const names = item.submenu.items.map((sub: any) => sub.name) const names = item.submenu.items.map((sub: any) => sub.name)
expect(keys).toEqual([ expect(keys).toEqual([
'export_file:csv', `${EXPORT_MENU_KEY}:csv`,
'export_file:xlsx', `${EXPORT_MENU_KEY}:xlsx`,
'export_file:pdf' `${EXPORT_MENU_KEY}:pdf`
]) ])
expect(names).toContain('PDF (.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 * 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 getFilename Returns the export file name at click time.
* @param skipLeadingCols Leading UI columns to exclude (see {@link exportGrid}). * @param skipLeadingCols Leading UI columns to exclude (see {@link exportGrid}).
@@ -332,28 +345,23 @@ export function buildExportMenuItem(
return { return {
name: 'Export', 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: {
// Submenu keys use the documented "parent_key:sub_key" form. The array // 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 // form is what HOT's own menu uses at runtime; cast to satisfy the
// satisfy the (stricter) MenuConfig typing. // (stricter) MenuConfig typing.
items: [ items: [
{ {
key: 'export_file:csv', key: `${EXPORT_MENU_KEY}:csv`,
name: 'CSV', name: 'CSV',
callback: makeCb('csv') callback: makeCb('csv')
}, },
{ {
key: 'export_file:xlsx', key: `${EXPORT_MENU_KEY}:xlsx`,
name: 'Excel (.xlsx)', name: 'Excel (.xlsx)',
callback: makeCb('xlsx') callback: makeCb('xlsx')
}, },
{ {
key: 'export_file:pdf', key: `${EXPORT_MENU_KEY}:pdf`,
name: 'PDF (.pdf)', name: 'PDF (.pdf)',
callback: makeCb('pdf') callback: makeCb('pdf')
} }
@@ -58,7 +58,13 @@
Errors ({{ programLog.errors!.length }}) Errors ({{ programLog.errors!.length }})
@for (issue of programLog.errors; track $index) { @for (issue of programLog.errors; track $index) {
<clr-tree-node <clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)" (click)="
goToLogLine(
issue,
programLog,
'request_' + i
)
"
> >
<button class="clr-treenode-link"> <button class="clr-treenode-link">
@if (issue.code) { @if (issue.code) {
@@ -84,7 +90,13 @@
track $index track $index
) { ) {
<clr-tree-node <clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)" (click)="
goToLogLine(
issue,
programLog,
'request_' + i
)
"
> >
<button class="clr-treenode-link"> <button class="clr-treenode-link">
@if (issue.code) { @if (issue.code) {
@@ -104,7 +116,13 @@
Notes ({{ programLog.notes!.length }}) Notes ({{ programLog.notes!.length }})
@for (issue of programLog.notes; track $index) { @for (issue of programLog.notes; track $index) {
<clr-tree-node <clr-tree-node
(click)="goToLogLine(issue, 'request_' + i)" (click)="
goToLogLine(
issue,
programLog,
'request_' + i
)
"
> >
<button class="clr-treenode-link"> <button class="clr-treenode-link">
@if (issue.code) { @if (issue.code) {
@@ -119,17 +137,41 @@
</clr-tree-node> </clr-tree-node>
} }
</clr-tree> </clr-tree>
@if (serverType !== 'SASVIYA') { @if (programLog.logWindow; as logWindow) {
<div @if (logWindow.visible.length) {
class="log-wrapper saslog" <div class="log-wrapper saslog">
[innerHTML]="programLog.logHtml" @for (line of logWindow.visible; track $index) {
></div> <div class="log-line">
} <span class="log-line-number">{{
@if (serverType === 'SASVIYA') { $index + 1
<div }}</span>
class="log-wrapper saslog viya" @if (programLog.logIsHtml) {
[textContent]="programLog.logFile" <span
></div> 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) { @if (programLog.logFile.length > 0) {
<button <button
@@ -147,9 +189,31 @@
<button clrTabLink>SAS Source Code</button> <button clrTabLink>SAS Source Code</button>
<ng-template [(clrIfActive)]="sasSourceCodeActive"> <ng-template [(clrIfActive)]="sasSourceCodeActive">
<clr-tab-content> <clr-tab-content>
<div class="log-wrapper"> @if (programLog.sourceWindow; as sourceWindow) {
{{ programLog.sourceCode }} <div class="log-wrapper">
</div> @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) { @if (programLog.sourceCode.length > 0) {
<button <button
(click)="downloadSourceCode(programLog.sourceCode)" (click)="downloadSourceCode(programLog.sourceCode)"
@@ -166,9 +230,34 @@
<button clrTabLink>SAS Generated Code</button> <button clrTabLink>SAS Generated Code</button>
<ng-template [(clrIfActive)]="sasGeneratedCodeActive"> <ng-template [(clrIfActive)]="sasGeneratedCodeActive">
<clr-tab-content> <clr-tab-content>
<div class="log-wrapper"> @if (programLog.generatedWindow; as generatedWindow) {
{{ programLog.generatedCode }} <div class="log-wrapper">
</div> @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) { @if (programLog.generatedCode.length > 0) {
<button <button
(click)=" (click)="
@@ -1,5 +1,9 @@
import { LogIssue } from '@sasjs/utils/logs' import { LogIssue, parseLog } from '@sasjs/utils/logs'
import { SasLogsModalComponent } from './sas-logs-modal.component' import {
FIRST_RENDERED_LINES,
MORE_RENDERED_LINES,
SasLogsModalComponent
} from './sas-logs-modal.component'
const LOG = [ const LOG = [
'1 data want;', '1 data want;',
@@ -35,7 +39,8 @@ describe('SasLogsModalComponent', () => {
const buildComponent = ( const buildComponent = (
requests: any[] = [], requests: any[] = [],
serverType: string = 'SAS9' serverType: string = 'SAS9',
changeDetector: any = { detectChanges: () => {} }
): SasLogsModalComponent => { ): SasLogsModalComponent => {
sasService = { sasService = {
getSasjsConfig: () => ({ appLoc: '/Public/app' }), getSasjsConfig: () => ({ appLoc: '/Public/app' }),
@@ -50,7 +55,8 @@ describe('SasLogsModalComponent', () => {
return new SasLogsModalComponent( return new SasLogsModalComponent(
sasService, sasService,
{ log: () => {} } as any, { log: () => {} } as any,
helperService helperService,
changeDetector
) )
} }
@@ -107,16 +113,65 @@ describe('SasLogsModalComponent', () => {
expect(request.logIssues).toEqual([]) expect(request.logIssues).toEqual([])
expect(request.errors).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', () => { describe('markIssueLines', () => {
it('marks every line an issue occupies, the wrapped ones included', () => { it('marks every line an issue occupies, the wrapped ones included', () => {
const request: any = { logFile: LOG } const lines = component.markIssueLines(LOG, parseLog(LOG)).split('\n')
component.analyseLog(request)
const lines: string[] = request.logHtml.split('\n')
expect(lines[5]).toEqual( expect(lines[5]).toEqual(
'<font class="warning-line">WARNING: Division by zero detected at line 3 column 11.</font>' '<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', () => { it('leaves a notable note unmarked, and the other lines untouched', () => {
const request: any = { logFile: LOG } const lines = component.markIssueLines(LOG, parseLog(LOG)).split('\n')
component.analyseLog(request)
const lines: string[] = request.logHtml.split('\n')
expect(lines[9]).toEqual('NOTE: Invalid data for age in line 42 3-5.') expect(lines[9]).toEqual('NOTE: Invalid data for age in line 42 3-5.')
expect(lines[0]).toEqual('1 data want;') 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', () => { describe('goToLogLine', () => {
let wrapper: HTMLElement 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') const stackBlock = document.createElement('div')
stackBlock.id = 'request_0' stackBlock.id = requestId
wrapper = document.createElement('div') wrapper = document.createElement('div')
wrapper.className = 'log-wrapper saslog' 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) stackBlock.appendChild(wrapper)
document.body.appendChild(stackBlock) document.body.appendChild(stackBlock)
// The height of a rendered line needs a layout; a fixed value keeps the return stackBlock
// arithmetic under test deterministic. }
;(component as any).measureLineHeight = () => 10
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: 40 }), undefined, 'request_0')
component.goToLogLine(issue({ line: 6 }), 'request_0')
expect( expect(wrapper.clientTop).toEqual(6)
document.documentElement.style.getPropertyValue('--line-top') expect(Math.abs(wrapper.scrollTop - (lineTop - 6))).toBeLessThan(1.5)
).toEqual('60px')
expect(
document.documentElement.style.getPropertyValue('--line-height')
).toEqual('10px')
expect(
document.documentElement.style.getPropertyValue('--line-count')
).toEqual('1')
}) })
it('does not jump to the first of two identical messages', () => { it('treats an issue that ends before it starts as one line', () => {
const repeated = issue({ line: 7, message: 'a message' }) 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( component.goToLogLine(
issue({ line: 5, severity: 'warning' }), issue({ line: 40, endLine: 0 }),
undefined,
'request_0' '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() it('does not jump to the first of two identical messages', () => {
expect(overlay!.classList).toContain('warning-highlight') 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', () => { it('does nothing when the request has no log on screen', () => {
expect(() => expect(() =>
component.goToLogLine(issue({ line: 1 }), 'request_9') component.goToLogLine(issue({ line: 1 }), undefined, 'request_9')
).not.toThrow() ).not.toThrow()
}) })
}) })
@@ -1,4 +1,5 @@
import { import {
ChangeDetectorRef,
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
@@ -21,11 +22,32 @@ interface SASjsRequestExtended extends SASjsRequest {
errors?: LogIssue[] errors?: LogIssue[]
warnings?: LogIssue[] warnings?: LogIssue[]
notes?: LogIssue[] notes?: LogIssue[]
/** The log with its error and warning lines wrapped in markup, for rendering. */ /** The log, the source and the generated code, as rendered lines. */
logHtml?: string logWindow?: LineWindow
sourceWindow?: LineWindow
generatedWindow?: LineWindow
/** Whether the log carries markup (SAS 9) or plain text (Viya, SASjs). */
logIsHtml?: boolean
selectedTable?: string 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({ @Component({
selector: 'app-sas-logs-modal', selector: 'app-sas-logs-modal',
templateUrl: './sas-logs-modal.component.html', templateUrl: './sas-logs-modal.component.html',
@@ -61,7 +83,8 @@ export class SasLogsModalComponent implements OnInit {
constructor( constructor(
private sasService: SasService, private sasService: SasService,
private loggerService: LoggerService, private loggerService: LoggerService,
private helperService: HelperService private helperService: HelperService,
private changeDetector: ChangeDetectorRef
) { ) {
this.sasjsConfig = this.sasService.getSasjsConfig() this.sasjsConfig = this.sasService.getSasjsConfig()
this.serverType = this.sasService.getServerType() 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 * 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 - * issues SAS wrote, grouped by severity, the log itself with those lines
* the log itself with those lines marked. * 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) { public analyseLog(request: SASjsRequestExtended) {
const log = typeof request.logFile === 'string' ? request.logFile : '' 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.errors = issues.filter((issue) => issue.severity === 'error')
request.warnings = issues.filter((issue) => issue.severity === 'warning') request.warnings = issues.filter((issue) => issue.severity === 'warning')
request.notes = issues.filter((issue) => issue.severity === 'note') 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 * Scrolls the log to an issue and flashes the lines it occupies, using the
* parser reported rather than searching the rendered text - a log repeats its * rendered line element rather than a search of the text or arithmetic over
* messages, so a search lands on the first of them every time. * 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( const logWrapper = document.querySelector(
`#${requestStackId} .log-wrapper.saslog` `#${requestStackId} .log-wrapper.saslog`
) as HTMLElement | null ) as HTMLElement | null
if (!logWrapper) return if (!logWrapper) return
const lineHeight = this.measureLineHeight(logWrapper) const rows = Array.from(
const lineCount = issue.endLine - issue.line + 1 logWrapper.querySelectorAll<HTMLElement>('.log-line')
const top = lineHeight * issue.line ).slice(issue.line, endLine + 1)
logWrapper.scrollTop = top const firstRow = rows[0]
// The height and position reach the stylesheet as CSS variables, which if (!firstRow) return
// 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`)
const overlay = document.createElement('div') // The border is outside the scrollable area, so it comes off the offset
overlay.className = `line-highlight-overlay ${issue.severity}-highlight` // rather than being scrolled past.
overlay.classList.add('temp-height-setter') logWrapper.scrollTop =
overlay.classList.add('line-position-setter') 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(() => { setTimeout(() => {
if (logWrapper.contains(overlay)) { for (const row of rows) {
logWrapper.removeChild(overlay) row.classList.remove(flashClass)
} }
}, 3000) }, 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) { downloadLog(logFile: string) {
const timestamp = new Date().valueOf() const timestamp = new Date().valueOf()
this.helperService.downloadTextFile(`logFile-${timestamp}`, logFile) 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 { LibraryClickEmitter } from 'src/app/shared/dc-tree/models/LibraryClickEmitter'
import { TableClickEmitter } from 'src/app/shared/dc-tree/models/TableClickEmitter' import { TableClickEmitter } from 'src/app/shared/dc-tree/models/TableClickEmitter'
import { mergeColsRules } from 'src/app/shared/dc-validator/utils/mergeColsRules' 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 { globals, initFilter } from 'src/app/_globals'
import { observeGridAria } from '../utils/grid-aria' import { observeGridAria } from '../utils/grid-aria'
import { ViewboxHotTable } from './models/viewbox-hot-table.model' import { ViewboxHotTable } from './models/viewbox-hot-table.model'
@@ -553,7 +556,7 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
items: { items: {
copy_with_column_headers: {}, copy_with_column_headers: {},
copy_column_headers_only: {}, copy_column_headers_only: {},
export_file: buildExportMenuItem(() => 'viewbox') [EXPORT_MENU_KEY]: buildExportMenuItem(() => 'viewbox')
} }
}, },
multiColumnSorting: true, multiColumnSorting: true,
+7 -2
View File
@@ -27,7 +27,10 @@ import { QueryComponent } from '../query/query.component'
import { FilterGroup, FilterQuery } from '../models/FilterQuery' import { FilterGroup, FilterQuery } from '../models/FilterQuery'
import { HotTableInterface } from '../models/HotTable.interface' 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 { LoggerService } from '../services/logger.service'
import Handsontable from 'handsontable' import Handsontable from 'handsontable'
import { import {
@@ -214,7 +217,9 @@ export class ViewerComponent
items: { items: {
copy_with_column_headers: {}, copy_with_column_headers: {},
copy_column_headers_only: {}, 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 // Navigates rather than just flipping `useLabels` locally, so the URL
// stays the single source of truth (shareable/refreshable state) — // stays the single source of truth (shareable/refreshable state) —
// the queryParams subscription in ngOnInit picks up the change and // the queryParams subscription in ngOnInit picks up the change and
+18
View File
@@ -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
View File
@@ -1568,10 +1568,6 @@ body[cds-theme='light'] {
// SAS-LOGS-MODAL.COMPONENT // SAS-LOGS-MODAL.COMPONENT
app-sas-logs-modal { app-sas-logs-modal {
--line-height: 16px;
--line-count: 1;
--line-top: 0px;
.issue-code { .issue-code {
margin-right: 4px; margin-right: 4px;
font-family: monospace; font-family: monospace;
@@ -1588,50 +1584,77 @@ app-sas-logs-modal {
} }
} }
.line-highlight-overlay { // One row per rendered line, so the number sits on the line it counts and a
position: absolute; // 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; left: 0;
width: 100%; z-index: 1;
pointer-events: none; flex: 0 0 auto;
animation: fadeOut 3s forwards; min-width: 3.5em;
z-index: 10; 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 { .log-line-text {
height: calc(var(--line-height) * var(--line-count)); flex: 1 1 auto;
min-width: 0;
} }
.line-position-setter { .log-more {
top: var(--line-top); margin: 4px 0 0;
font-size: 0.7rem;
color: #6b6b6b;
} }
.temp-line-height-calc { .log-line.error-highlight {
position: absolute; animation: flash-error 3s ease-out forwards;
visibility: hidden;
height: auto;
} }
.error-highlight { .log-line.warning-highlight {
background-color: rgba(255, 0, 0, 0.2); animation: flash-warning 3s ease-out forwards;
} }
.warning-highlight { .log-line.note-highlight {
background-color: rgba(242, 175, 0, 0.25); animation: flash-note 3s ease-out forwards;
} }
.note-highlight { @keyframes flash-error {
background-color: rgba(0, 120, 210, 0.15); from {
} background-color: rgba(255, 0, 0, 0.2);
@keyframes fadeOut {
0% {
opacity: 1;
} }
70% { to {
opacity: 1; 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;
} }
} }
+12
View File
@@ -9,6 +9,18 @@ const gitInfo = gitDescribeSync({
dirtySemver: false 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 //Reading the installed adapter version
const adapterPackagePath = resolve( const adapterPackagePath = resolve(
__dirname, __dirname,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dcfrontend", "name": "dcfrontend",
"version": "7.21.0", "version": "7.22.0",
"description": "Data Controller", "description": "Data Controller",
"devDependencies": { "devDependencies": {
"@nogoo9/gitleaks": "8.30.1-post.2", "@nogoo9/gitleaks": "8.30.1-post.2",
+2 -1
View File
@@ -2,7 +2,8 @@
"SASControlTable": [ "SASControlTable": [
{ {
"ACTION": "MAKEDATA", "ACTION": "MAKEDATA",
"ADMIN": "AllUsers" "ADMIN": "AllUsers",
"OUTFMT": "JSON"
} }
] ]
} }
@@ -325,14 +325,14 @@ const data = {
{ {
ODS_TABLE: 'ENGINEHOST', ODS_TABLE: 'ENGINEHOST',
NAME: 'Filename', 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: 'Release Created', VALUE: '9.0401M7' },
{ ODS_TABLE: 'ENGINEHOST', NAME: 'Host Created', VALUE: 'X64_DSRV16' }, { ODS_TABLE: 'ENGINEHOST', NAME: 'Host Created', VALUE: 'X64_DSRV16' },
{ {
ODS_TABLE: 'ENGINEHOST', ODS_TABLE: 'ENGINEHOST',
NAME: 'Owner Name', NAME: 'Owner Name',
VALUE: 'BUILTINAdministrators' VALUE: 'BUILTIN\\Administrators'
}, },
{ ODS_TABLE: 'ENGINEHOST', NAME: 'File Size', VALUE: ' 1MB' }, { ODS_TABLE: 'ENGINEHOST', NAME: 'File Size', VALUE: ' 1MB' },
{ ODS_TABLE: 'ENGINEHOST', NAME: 'File Size (bytes)', VALUE: '1048576' } { ODS_TABLE: 'ENGINEHOST', NAME: 'File Size (bytes)', VALUE: '1048576' }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,73 +1,30 @@
_webout = `{"SYSDATE" : "26SEP22" /* A fixed sample of what the SAS service returns for this screen.
,"SYSTIME" : "08:54" The members are the same shape; the envelope now comes from the runtime. */
, "fromsas": webout('OPEN')
[ webout('OBJ', 'fromsas', [
{ {
"STRING": "digraph G { concentrate=true; node [style=filled,shape=plain]; labelloc = "t";" 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>>"
}, },
{ {
"STRING": "x [label="No lineage found" shape=Mdiamond]} 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>>"
] },
, "clickableids":
[
]
, "info":
[
{ {
"COLURI": "", STRING: 'x [label="No lineage found" shape=Mdiamond]}'
"COLNAME": "changed_records",
"TABURI": "OMSOBJ:PhysicalTable\A59LNVZG.BR00002J",
"TABNAME": "MPE_DATALOADS",
"LIBURI": "OMSOBJ:SASLibrary\A59LNVZG.B500000K",
"LIBREF": "DC_JSLIB",
"RC": 0
} }
] ])
, "flatdata": webout('OBJ', 'clickableids', [])
[ webout('OBJ', 'info', [
{
] COLURI: '',
,"_DEBUG" : "" COLNAME: 'changed_records',
,"_METAUSER": "sasdemo@SAS" TABURI: 'OMSOBJ:PhysicalTable\\A59LNVZG.BR00002J',
,"_METAPERSON": "sasdemo" TABNAME: 'MPE_DATALOADS',
,"_PROGRAM" : "/Projects/app/dc/services/lineage/fetchcollineage" LIBURI: 'OMSOBJ:SASLibrary\\A59LNVZG.B500000K',
,"AUTOEXEC" : "D%3A%5Copt%5Csasinside%5CConfig%5CLev1%5CSASApp%5CStoredProcessServer%5Cautoexec.sas" LIBREF: 'DC_JSLIB',
,"MF_GETUSER" : "sasdemo" RC: 0
,"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', 'flatdata', [])
webout('CLOSE')
+174
View File
@@ -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'
)
})
+8 -2
View File
@@ -168,12 +168,18 @@ function runService(
return stored || returned 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() const estate = makeEstate()
runService(estate, 'admin/makedata', { runService(estate, 'admin/makedata', {
params: { action: 'MAKEDATA', admin: 'AllUsers' } params: { action: 'MAKEDATA', admin: 'AllUsers' }
}) })
if (demo) runService(estate, 'admin/createdemodata', {})
return estate return estate
} }
+19
View File
@@ -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', () => { test('MAKEDATA seeds the control library and then removes itself', () => {
const estate = makeEstate() const estate = makeEstate()
+242
View File
@@ -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')
})
+285
View File
@@ -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)),
[]
)
})
+122
View 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 mf_mkdir.sas
@li mp_abort.sas @li mp_abort.sas
@li mpe_action.sas @li mpe_action.sas
@li ms_getfile.sas
@li mpe_dirlist.sas @li mpe_dirlist.sas
@li mpe_writecheck.sas @li mpe_writecheck.sas
@li mpe_makedata.sas @li mpe_makedata.sas
@@ -274,37 +273,15 @@ run;
%mend makedata_body; %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 * DIRLIST and WRITECHECK exist for the deploy screen, and the deploy window
* failed after writing it leaves the file behind - so its presence, not the * closes by itself: a successful run deletes this program, so once DC is
* survival of this STP, is what says the system is configured. Without this * configured there is nothing here left to call. A gate keyed off
* a failed or abandoned install leaves a caller with a directory listing of * settings.sas closed the window on the runs that still needed it - a failed
* any path the SAS user can read. * 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.
* A probe that cannot answer is treated as configured: an unreachable drive
* is not evidence that the deployment window is still open.
*/ */
%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 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, is asserted against the macros directly (macros/mpe_dirlist.test.sas,
macros/mpe_writecheck.test.sas); this file covers the dispatch around them. 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, The browse actions carry no gate of their own: a successful run deletes this
and any appLoc running this suite has one - so here the two actions are program, so the deploy window closes by itself. One of the tests below calls
expected to be refused by the gate. What that proves is that the action DIRLIST on an appLoc that already has a settings.sas, which is what a retry
reached the gate at all, which is the part the dispatch owns. after a failed install looks like.
<h4> SAS Macros </h4> <h4> SAS Macros </h4>
@li mpe_assert_abort.sas @li mpe_assert_abort.sas
@@ -82,49 +82,16 @@ run;
outds=work.test_results outds=work.test_results
) )
/** /*
* Test 4 - DIRLIST is refused once the appLoc is configured. A run that There is no test here for the browse actions themselves.
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
)
/** They used to be refused once settings.sas existed, and that refusal is what
* Test 5 - and so is WRITECHECK 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
data work.fromjs; same lifetime. DIRLIST and WRITECHECK are asserted against the macros that
length action $10 path $1000; implement them (macros/mpe_dirlist.test.sas, macros/mpe_writecheck.test.sas);
action='WRITECHECK'; the dispatch around them is what tests 1-3 cover.
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
)
/** /**
* dump results to the log for offline inspection * dump results to the log for offline inspection
@@ -24,7 +24,6 @@
@li mf_trimstr.sas @li mf_trimstr.sas
@li mp_abort.sas @li mp_abort.sas
@li mpe_action.sas @li mpe_action.sas
@li mfv_existfile.sas
@li mpe_dirlist.sas @li mpe_dirlist.sas
@li mpe_writecheck.sas @li mpe_writecheck.sas
@li mpe_makedata.sas @li mpe_makedata.sas
@@ -366,25 +365,15 @@ run;
%mend makedata_body; %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 * DIRLIST and WRITECHECK exist for the deploy screen, and the deploy window
* failed after writing it leaves the file behind - so its presence, not the * closes by itself: a successful run deletes this program, so once DC is
* survival of this STP, is what says the system is configured. Without this * configured there is nothing here left to call. A gate keyed off
* a failed or abandoned install leaves a caller with a directory listing of * settings.sas closed the window on the runs that still needed it - a failed
* any path the SAS user can read. * 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 Three invocations rather than one branch: each macro takes `iftrue=` and