Files
dc/sas/mocks/test/harness.js
T
dc ca602082ff
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 8m58s
Build / Build-and-test-development (pull_request) Successful in 29m42s
fix(mocks): abort like mp_abort, lock like mp_lockanytable, derive saslibs
Three more places where the mock answered differently from the SAS service.

mp_abort (dcMockUtils): every mock refusal is now a sasjsAbort table
(MSG + MAC) written where the macro writes it, so the client shows the
abort modal and rejects the promise. A thrown error had become an HTTP
500 "Job execution failed" - the browser host discards the webout when a
program throws - which is unreadable to the user and hides the message in
a stack. mpAbort also stops the program, so nothing is written after a
refusal; each service that can abort catches that stop at its top level.
stagedata, loadfile, getauditfile and makedata now go through it, and the
hand-rolled sasjsAbort blocks are gone.

mp_lockanytable (dcMockUtils): a lock is a row in MPE_LOCKANYTABLE marked
LOCKED carrying the holder's job id, user and reference, exactly as the
macro records it, and a lock held by another session refuses the write
with the macro's message. postdata and rejection now lock MPE_REVIEW and
MPE_SUBMIT around each write, which is where postdata.sas and
rejection.sas take them. The macro's retry loop is collapsed to a single
attempt: the runtime is single threaded, so nothing can release a lock
while the request waits.

startupservice: saslibs is the distinct LIBREF of the permitted datasets,
which is what "create table saslibs as select distinct libref from
&syslast" produces - it was a four-column row describing the control
library, with fields the client does not read.

Tests: abort.test.js and locks.test.js join the harness suites; 25 pass,
and 9 of the 13 relevant cases fail against the base services.
2026-11-04 15:37:31 +00:00

204 lines
7.2 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'), '')
return {
root,
drive,
appDir,
servicesDir,
pickme: path.join(root, 'pickme'),
readonly: path.join(root, 'readonly'),
weboutPath: path.join(sessionDir, 'webout.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 _program = ' + JSON.stringify(APP_LOC + '/services/' + program) + ';',
"const _METAPERSON = 'DEMO';",
"const _METAUSER = '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) {',
" const cmd = String(action === undefined ? '' : action).toUpperCase();",
" if (cmd === 'OPEN') { __wb = {}; return; }",
" if (cmd === 'CLOSE') { _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;",
'}',
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 exactly as the deploy screen does, then returns it. */
function seededEstate() {
const estate = makeEstate()
runService(estate, 'admin/makedata', {
params: { action: 'MAKEDATA', admin: 'AllUsers' }
})
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
}