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.
204 lines
7.2 KiB
JavaScript
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
|
|
}
|