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.
232 lines
8.7 KiB
JavaScript
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
|
|
}
|