Files
dc/sas/mocks/test/harness.js
T
dc 49a9bbf022
Build / Build-and-ng-test (pull_request) Successful in 2m49s
Build / Build-and-test-development (pull_request) Successful in 12s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m15s
feat(mocks): record getdata and viewlibs, and show what the mock gets wrong
Two more scenarios, and the recorder could not take either of them as it stood.

- editors/getdata reads a table input, not URL parameters, so record.js can now
  post one: it writes the informational header the server expects (the first
  line is the SAS input statement, not a list of column names), posts it as
  multipart/form-data, and takes --libref so the estate's control library name
  stays out of scenarios.json. replay.js hands the mock its own rows through
  mockInput, since the two sides do not share a libref.
- the test harness's webout() stub now honours the showmeta option and writes
  the body as members are added. Without them the mocks' own
  requireMemberMetadata() guard throws - it reads the body before CLOSE - and a
  service that stopped sending formats and labels would have passed here.
- --scenario accepts service/id, because two screens legitimately call their
  first service 'default'.

The two goldens are recorded but marked gated: false: the gate prints their
difference counts and does not fail, and check.js labels them ADVISORY. They are
a work list, not an acceptance.

editors/getdata/mpe_tables - 388 differences, all of them the mock's:
cols carries a DESC key SAS never emits and misses the LABEL/LONGDESC/COLTYPE
SAS reads from MPE_DATADICTIONARY; sasdata leaves blank character values out
instead of padding them and is not ordered by the business key; dqrules differ
in content and order; approvers is hardcoded to one user where SAS computes it
and returns none.

public/viewlibs - 4 differences: SAS answers LIBRARYID='V9' with a blank ENGINE
and LIBRARYNAME equal to the libref, and lists SASHELP; the mock answers
LIBRARYID=<libref>, ENGINE='V9', LIBRARYNAME='Data Controller(<libref>)' and has
no SASHELP.

dsmeta is excluded on purpose: it is the engine's report on the dataset (owner,
host, encoding, paths, created dates), estate state rather than behaviour, and
the projection refused the recorded response until it was dropped - the site id
and the service account name were in it.
2026-11-06 21:28:51 +00:00

232 lines
8.7 KiB
JavaScript

/**
* Runs a mock service file the way the estates do.
*
* The services are plain scripts: the runtime predeclares `fs`, the drive
* paths, the input table as `_WEBIN_*` consts and any URL parameters as
* consts, then reads `_webout`. This harness reproduces that in node against
* a temporary estate, so a test exercises the file that ships rather than a
* copy of its logic.
*/
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const SERVICES = path.join(__dirname, '..', 'sasjs', 'services')
const APP_LOC = '/Public/app/dc'
// an estate is a few MB of seed data; a suite makes one per case, so they are
// removed when the process ends rather than left to fill the temp filesystem
const created = []
process.on('exit', () => {
for (const dir of created) {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch (err) {
/* a leftover temp dir is not a test failure */
}
}
})
/** A minimal SASjs estate: a drive, every service, and a folder to browse. */
function makeEstate() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mock-'))
created.push(root)
const drive = path.join(root, 'drive')
const appDir = path.join(drive, 'files', ...APP_LOC.split('/').filter(Boolean))
const servicesDir = path.join(appDir, 'services')
// every service, so hooks and shared utilities resolve as they do live
fs.mkdirSync(servicesDir, { recursive: true })
for (const entry of fs.readdirSync(SERVICES, { withFileTypes: true })) {
const from = path.join(SERVICES, entry.name)
const to = path.join(servicesDir, entry.name)
if (entry.isDirectory()) {
fs.mkdirSync(to, { recursive: true })
for (const file of fs.readdirSync(from)) {
fs.copyFileSync(path.join(from, file), path.join(to, file))
}
} else {
fs.copyFileSync(from, to)
}
}
// browsing starts at the platform root, so the folder to pick lives above
// the drive, as /tmp does on a real deployment
fs.mkdirSync(path.join(root, 'pickme', 'child'), { recursive: true })
fs.mkdirSync(path.join(root, 'pickme', 'second'), { recursive: true })
fs.writeFileSync(path.join(root, 'pickme', 'not-a-folder.txt'), 'x')
fs.mkdirSync(path.join(root, 'readonly'), { recursive: true })
const sessionDir = path.join(root, 'sessions', 's1')
fs.mkdirSync(sessionDir, { recursive: true })
fs.writeFileSync(path.join(sessionDir, 'webout.txt'), '')
fs.writeFileSync(path.join(sessionDir, 'stpsrv_header.txt'), '')
return {
root,
drive,
appDir,
servicesDir,
pickme: path.join(root, 'pickme'),
readonly: path.join(root, 'readonly'),
weboutPath: path.join(sessionDir, 'webout.txt'),
headersPath: path.join(sessionDir, 'stpsrv_header.txt')
}
}
/** The adapter's CSV: a space separated header of NAME:$charN., then rows. */
function toCsv(columns, rows) {
const header = columns.map((name) => name + ':$char1000.').join(' ')
const lines = rows.map((row) =>
columns
.map((name) => {
const value = row[name] === undefined ? '' : String(row[name])
return /[",\r\n]/.test(value)
? '"' + value.replace(/"/g, '""') + '"'
: value
})
.join(',')
)
return [header, ...lines].join('\r\n') + '\r\n'
}
/**
* Executes `program` (a path below services/, without extension) as the
* runtime does: drive paths and input table are module-scope consts, the body
* assigns `_webout`, which is returned.
*/
function runService(
estate,
program,
{ rows, columns, tableName = 'fromjs', params = {} } = {}
) {
const source = fs.readFileSync(
path.join(estate.servicesDir, program + '.js'),
'utf8'
)
const preamble = [
'const weboutPath = ' + JSON.stringify(estate.weboutPath) + ';',
'const _SASJS_WEBOUT_HEADERS = ' + JSON.stringify(estate.headersPath) + ';',
'const _program = ' + JSON.stringify(APP_LOC + '/services/' + program) + ';',
"const _METAPERSON = 'DEMO';",
"const _METAUSER = 'DEMO';",
"const _SASJS_USERNAME = 'DEMO';",
'let _webout = "";',
// The deployment injects the generated webout() prelude; a test only needs
// the three calls the mocks make, with the same validation and the same
// shape, so a service that misuses OBJ/ARR fails here too.
'let __wb = null;',
'function webout(action, name, rows, options) {',
" const cmd = String(action === undefined ? '' : action).toUpperCase();",
" if (cmd === 'OPEN') { __wb = {}; return; }",
" if (cmd === 'CLOSE') {",
" __wb = __wb || {};",
// the server appends these to every webout() envelope; a harness without
// them lets a service that stopped emitting a member pass unnoticed
" __wb.SYSCC = '0';",
" __wb.SYSENCODING = 'utf-8';",
" _webout = JSON.stringify(__wb);",
" fs.writeFileSync(weboutPath, _webout);",
" return;",
" }",
" if (cmd !== 'OBJ' && cmd !== 'ARR') throw new Error('unknown webout action ' + action);",
" if (!__wb) throw new Error(\"webout('\" + action + \"') requires webout('OPEN') first\");",
" if (!name) throw new Error(\"webout('\" + action + \"') needs a member name\");",
" if (!Array.isArray(rows)) throw new Error(\"webout('\" + action + \"') needs an array of rows\");",
" if (cmd === 'ARR' && rows.some(r => !Array.isArray(r))) throw new Error(\"webout('ARR') takes positional rows\");",
" if (cmd === 'OBJ' && rows.some(r => !r || typeof r !== 'object' || Array.isArray(r))) throw new Error(\"webout('OBJ') takes rows keyed by column name\");",
" __wb[name] = rows;",
// @sasjs/utils 4.6.0 writes the column metadata as $<member> when the
// caller passes { showmeta }. A harness that dropped it would make the
// mock's own requireMemberMetadata guard throw, and a service that stopped
// emitting formats and labels would look fine here.
" if (options && options.showmeta) __wb['$' + name] = options.showmeta;",
// the real webout() streams to the response file as members are added, and
// the mocks' own guard reads that file before CLOSE to prove the metadata
// arrived - so a stub that only wrote at CLOSE makes the guard throw
" fs.writeFileSync(weboutPath, JSON.stringify(__wb));",
'}',
Object.entries(params)
.map(
([name, value]) => `const ${name} = ${JSON.stringify(String(value))};`
)
.join('\n')
].join('\n')
let webin = 'const _WEBIN_FILE_COUNT = 0;'
if (rows) {
const csv = toCsv(columns || Object.keys(rows[0]), rows)
webin = [
'const _WEBIN_NAME1 = ' + JSON.stringify(tableName) + ';',
"const _WEBIN_FILENAME1 = 'input.csv';",
'const _WEBIN_FILEREF1 = ' + JSON.stringify(csv) + ';',
'const _WEBIN_FILE_COUNT = 1;'
].join('\n')
}
const body = preamble + '\n' + webin + '\n' + source + '\nreturn _webout;'
const fn = new Function('require', 'console', 'process', 'Buffer', 'fs', body)
// the services log their progress; keep the test output readable
const quiet = { log() {}, info() {}, warn() {}, error() {} }
let returned = ''
try {
returned = fn(require, quiet, process, Buffer, fs)
} catch (err) {
// mpAbort() writes its response and then stops the program, as the SAS soft
// abort does; the runtime still returns the webout, so the harness must too
if (!err || !err.mockAbort) throw err
}
const stored = fs.existsSync(estate.weboutPath)
? fs.readFileSync(estate.weboutPath, 'utf8')
: ''
return stored || returned
}
/**
* Seeds the estate the way the CI estate is built: makedata for the control
* library, then createdemodata for the Cypress fixtures. Pass
* `{ demo: false }` for the control library alone, which is what a real
* deployment gets.
*/
function seededEstate({ demo = true } = {}) {
const estate = makeEstate()
runService(estate, 'admin/makedata', {
params: { action: 'MAKEDATA', admin: 'AllUsers' }
})
if (demo) runService(estate, 'admin/createdemodata', {})
return estate
}
/** The file one control table lives in, on the estate's drive. */
function tableFile(estate, name) {
return path.join(estate.appDir, 'data', 'DC_JSLIB', name.toLowerCase() + '.json')
}
/** The rows of a control table, as the drive stores them. */
function readTable(estate, name) {
const table = JSON.parse(fs.readFileSync(tableFile(estate, name), 'utf8'))
return table.rows || []
}
/** Rewrites one control table's rows, leaving its schema alone. */
function writeTable(estate, name, rows) {
const file = tableFile(estate, name)
const table = JSON.parse(fs.readFileSync(file, 'utf8'))
table.rows = rows
fs.writeFileSync(file, JSON.stringify(table, null, 2))
}
module.exports = {
makeEstate,
seededEstate,
runService,
toCsv,
readTable,
writeTable,
APP_LOC
}