feat(mocks): run pre/post edit hook programs in the JS mock services
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m52s
Build / Build-and-test-development (pull_request) Successful in 25m48s

The JS mock services carried MPE_TABLES.pre_edit_hook / post_edit_hook values
but never acted on them, so a table configured with hooks behaved differently
in the mock than in production.

Add mockHookSource() to dcMockUtils (resolving a hook program name to the
source of its .js counterpart on the Drive) and run the hook where the SAS
backend does:

- editors/getdata runs the pre-edit hook after filtering, letting it reassign
  visibleRows / visibleColumns (the work.OUT contract) - so an empty mirror can
  display the live rows of the table it points at
- editors/stagedata and editors/loadfile run the post-edit hook before the
  MPE_SUBMIT row is written, letting it reassign libref / dsn (the call
  symputx contract) - so a changeset submitted against a mirror is raised
  against the real table

Seed a demo pair in makedata (TESTDATA.DEMO_ORDERS plus an empty
TESTDATA.DEMO_MIRROR with both hooks) and cover it with a Cypress spec.
This commit is contained in:
dc
2026-09-24 15:17:07 +00:00
parent f1734a2de0
commit b5f228351b
8 changed files with 426 additions and 7 deletions
+189
View File
@@ -0,0 +1,189 @@
// Marks this file as an ES module (rather than a global script) so its
// top-level consts don't collide, under the TS type-checker, with the same
// names declared in other spec files.
export {}
const hostUrl = Cypress.env('hosturl')
const appLocation = Cypress.env('appLocation')
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
context('hook program tests: ', function () {
this.beforeAll(() => {
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
visitPage('home')
})
// TESTDATA.DEMO_MIRROR is an empty mirror of TESTDATA.DEMO_ORDERS, registered
// with a PRE_EDIT_HOOK and a POST_EDIT_HOOK (services/hooks/demo_mirror_*.js).
// The mock services run those hooks the way the SAS backend %includes the
// real hook programs - see dcMockUtils.mockHookSource.
it('1 | PRE_EDIT_HOOK: an empty mirror displays the live rows of its target table', () => {
openTableFromTree('testdata', 'demo_mirror')
// The mirror holds no rows of its own: everything on screen comes from the
// pre-edit hook, which loads TESTDATA.DEMO_ORDERS.
cy.get('#hotTable .ht_master tbody tr', {
timeout: longerCommandTimeout
}).should('have.length', 4)
cy.get('#hotTable .ht_master tbody tr')
.first()
.should('contain.text', 'Acme Corp')
cy.get('#hotTable .ht_master tbody tr')
.last()
.should('contain.text', 'Delta Systems')
// The grid is validated against the MIRROR's own rule set (AMOUNT <= 2000),
// not the real table's (AMOUNT <= 100000) - that is the point of routing
// edits through a mirror.
clickOnEdit(() => {
editCell(0, 'AMOUNT', '5000')
})
cy.get('#hotTable .ht_master tbody tr')
.first()
.find('td.htInvalid')
.should('exist')
})
it('2 | POST_EDIT_HOOK: a change submitted against the mirror is raised against the target', () => {
openTableFromTree('testdata', 'demo_mirror')
clickOnEdit(() => {
editCell(0, 'AMOUNT', '750')
})
submitTable(() => {
cy.get('textarea.submit-reason', { timeout: longerCommandTimeout }).type(
'hook routing test'
)
submitTableMessage()
})
// A successful submit sends the app to the staged-data page; wait for that
// before navigating away, or the app's own redirect wins the race.
cy.url({ timeout: longerCommandTimeout }).should('include', '/stage/')
// The post-edit hook re-points the changeset at TESTDATA.DEMO_ORDERS, so the
// approval queue names the real table rather than the mirror.
visitPage('review/approve')
cy.get('clr-datagrid clr-dg-row', { timeout: longerCommandTimeout })
.last()
.should('contain.text', 'TESTDATA.DEMO_ORDERS')
})
})
// ─── helpers (declared at the bottom, per the other specs) ───────────────────
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout })
.should('not.exist')
.then(() => {
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then((treeNodes: any) => {
let viyaLib
for (let node of treeNodes) {
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
viyaLib = node
break
}
}
cy.get(viyaLib).within(() => {
cy.wait(300)
cy.get(
'.clr-tree-node-content-container .clr-treenode-content p'
).click()
cy.get('.clr-treenode-link').then((innerNodes: any) => {
for (let innerNode of innerNodes) {
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
break
}
}
})
})
})
})
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
}).should('exist')
}
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
// Edits a single cell, locating the column by its header label. Handsontable
// renders the frozen header in a separate clone pane (.ht_clone_top) - the
// header row inside .ht_master is kept visibility:hidden - so the labels are
// read from the clone.
const editCell = (rowIndex: number, colName: string, value: string) => {
cy.get('#hotTable .ht_clone_top .htCore thead th', {
timeout: longerCommandTimeout
}).then((headers: any) => {
let colIndex = -1
for (let i = 0; i < headers.length; i++) {
if (
String(headers[i].innerText || '')
.trim()
.toUpperCase() === colName.toUpperCase()
) {
colIndex = i
break
}
}
expect(colIndex, `column ${colName} present`).to.be.greaterThan(-1)
cy.get('#hotTable .ht_master tbody tr')
.eq(rowIndex)
.then((row: any) => {
cy.get(row[0].childNodes[colIndex])
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type(value + '{enter}')
})
})
})
}
const submitTable = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
.then(() => {
if (callback) callback()
})
}
const submitTableMessage = (callback?: any) => {
cy.get('.modal-footer .btn.btn-sm.btn-success-outline', {
timeout: longerCommandTimeout
})
.click()
.then(() => {
if (callback) callback()
})
}
+67 -1
View File
@@ -5259,12 +5259,40 @@ const testTables = {
STATUS: 1,
LAST_REVIEWED: 23621
}
]
],
// Hook demo pair: DEMO_MIRROR is an EMPTY mirror of DEMO_ORDERS, registered
// with pre/post edit hooks (services/hooks/demo_mirror_*.js). The pre-edit
// hook shows the live rows of DEMO_ORDERS; the post-edit hook routes a
// changeset submitted against the mirror to DEMO_ORDERS.
DEMO_ORDERS: [
{ ORDER_ID: 1001, CUSTOMER: 'Acme Corp', REGION: 'EU', AMOUNT: 450, STATUS: 'NEW' },
{ ORDER_ID: 1002, CUSTOMER: 'Borealis Ltd', REGION: 'EU', AMOUNT: 980.5, STATUS: 'OPEN' },
{ ORDER_ID: 1003, CUSTOMER: 'Cobalt GmbH', REGION: 'EU', AMOUNT: 620, STATUS: 'CLOSED' },
{ ORDER_ID: 1004, CUSTOMER: 'Delta Systems', REGION: 'US', AMOUNT: 4500, STATUS: 'NEW' }
],
DEMO_MIRROR: []
}
// Schemas for the TESTDATA tables, matching the old inline getdata.js payload
// that the Cypress editor specs were calibrated against.
const testSchema = {
// Hook demo pair (see DEMO_ORDERS / DEMO_MIRROR in testTables). The mirror
// has the same shape as the real table, so the pre-edit hook can hand its rows
// straight to the grid.
DEMO_ORDERS: [
{ name: 'ORDER_ID', type: 'N', length: 8, format: '', label: 'Order Id', notnull: true },
{ name: 'CUSTOMER', type: 'C', length: 20, format: '', label: 'Customer', notnull: false },
{ name: 'REGION', type: 'C', length: 8, format: '', label: 'Region', notnull: false },
{ name: 'AMOUNT', type: 'N', length: 8, format: '', label: 'Amount', notnull: false },
{ name: 'STATUS', type: 'C', length: 12, format: '', label: 'Status', notnull: false }
],
DEMO_MIRROR: [
{ name: 'ORDER_ID', type: 'N', length: 8, format: '', label: 'Order Id', notnull: true },
{ name: 'CUSTOMER', type: 'C', length: 20, format: '', label: 'Customer', notnull: false },
{ name: 'REGION', type: 'C', length: 8, format: '', label: 'Region', notnull: false },
{ name: 'AMOUNT', type: 'N', length: 8, format: '', label: 'Amount', notnull: false },
{ name: 'STATUS', type: 'C', length: 12, format: '', label: 'Status', notnull: false }
],
MPE_X_NEW: [
{
name: 'PRIMARY_KEY_FIELD',
@@ -5631,6 +5659,28 @@ tables.MPE_TABLES.push(
loadtype: 'UPDATE',
buskey: 'ID',
notes: 'Compact rule demo table (eight columns)'
},
{
tx_from: 0,
tx_to: 127490111999,
libref: testDataLibref,
dsn: 'DEMO_ORDERS',
num_of_approvals_required: 1,
loadtype: 'UPDATE',
buskey: 'ORDER_ID',
notes: 'Hook demo - the real table the mirror points at'
},
{
tx_from: 0,
tx_to: 127490111999,
libref: testDataLibref,
dsn: 'DEMO_MIRROR',
num_of_approvals_required: 1,
loadtype: 'UPDATE',
buskey: 'ORDER_ID',
notes: 'Hook demo - an empty mirror whose hooks show and route DEMO_ORDERS',
pre_edit_hook: 'services/hooks/demo_mirror_preedit',
post_edit_hook: 'services/hooks/demo_mirror_postedit'
}
)
@@ -5854,6 +5904,22 @@ const testValidations = [
base_col: 'STATUS',
rule_type: 'SOFTSELECT',
rule_value: testDataLibref + '.DEMO_01.STATUS'
},
// Hook demo: the mirror carries its OWN rule set, separate from the real
// table's - which is the whole point of routing edits through a mirror.
{
base_lib: testDataLibref,
base_ds: 'DEMO_MIRROR',
base_col: 'AMOUNT',
rule_type: 'MAXVAL',
rule_value: '2000'
},
{
base_lib: testDataLibref,
base_ds: 'DEMO_ORDERS',
base_col: 'AMOUNT',
rule_type: 'MAXVAL',
rule_value: '100000'
}
]
for (const v of testValidations) {
+33
View File
@@ -336,6 +336,39 @@ function loadTableSchema(dataDir, tableName) {
return (data && data.columns) ? data.columns : []
}
/**
* Resolves a hook program name to the source of its mock implementation.
*
* MPE_TABLES.pre_edit_hook / post_edit_hook (and the SOFTSELECT_HOOK /
* HARDSELECT_HOOK rule_value in MPE_VALIDATIONS) hold a hook program: either a
* physical .sas path, or a path relative to the appLoc (e.g.
* 'services/hooks/mytable_postedit'). The real backend %includes the program
* into the running service, so the hook shares the service's macro variables
* and work datasets and can modify them.
*
* The mock mirrors that: this returns the source of the hook's .js counterpart,
* which the calling service eval()s in its OWN scope so the hook can read and
* modify the service's variables. Returns null when the hook has no mock
* implementation, so callers can fall back to an inline emulation or ignore it.
*
* Requires nodePath, driveRoot and appLoc in the caller's scope.
*/
function mockHookSource(hookValue) {
if (!hookValue) return null
let hook = String(hookValue).trim()
if (!hook) return null
// Reduce to the services-relative form, dropping any drive path or appLoc
// prefix the config may carry.
const idx = hook.indexOf('services/')
if (idx > -1) hook = hook.slice(idx)
// A physical hook is a .sas program on the server; its mock is a .js file
// sitting in the same place on the Drive.
hook = hook.replace(/\.sas$/i, '')
const hookFile = nodePath.resolve(driveRoot, 'files', appLoc, hook + '.js')
if (!fs.existsSync(hookFile)) return null
return fs.readFileSync(hookFile, 'utf8')
}
/**
* Synthesises NOTNULL dqrules from the table schema, mirroring the
* dictionary.columns union in getdata.sas:
+19 -2
View File
@@ -204,8 +204,8 @@ if (tableData && tableData.columns && tableData.rows) {
if (tableReg.var_busto) excludeCols.add(tableReg.var_busto.toUpperCase())
}
const visibleColumns = tableData.columns.filter(c => !excludeCols.has(c.name.toUpperCase()))
const visibleRows = tableData.rows
let visibleColumns = tableData.columns.filter(c => !excludeCols.has(c.name.toUpperCase()))
let visibleRows = tableData.rows
.filter(r => filterPredicate(r))
.map(r => {
const copy = {}
@@ -215,6 +215,23 @@ if (tableData && tableData.columns && tableData.rows) {
return copy
})
// ─── PRE_EDIT_HOOK ───────────────────────────────────────────────────────
// Mirrors getdata.sas: %mpe_runhook(PRE_EDIT_HOOK) runs after the filter has
// been applied and the rows sorted, with the data in work.OUT, and may
// replace it. The mock eval()s the hook's .js counterpart in this scope, so
// the hook may reassign visibleRows / visibleColumns - e.g. to show the live
// rows of another table behind an empty mirror.
if (tableReg && tableReg.pre_edit_hook) {
const hookSrc = mockHookSource(tableReg.pre_edit_hook)
if (hookSrc) {
try {
eval(hookSrc)
} catch (err) {
console.log('Error running pre_edit_hook ' + tableReg.pre_edit_hook + ': ' + err.message)
}
}
}
// Augment rows: add delete flag, normalise keys, convert temporal values to ISO
const colLookup = {}
for (const col of visibleColumns) {
+25 -2
View File
@@ -59,8 +59,8 @@ for (let i = 1; i <= 10; i++) {
let libds = ''
if (typeof table !== 'undefined' && table) libds = String(table).toUpperCase()
const libref = libds.split('.')[0] || dcLibref
const dsn = libds.split('.')[1] || ''
let libref = libds.split('.')[0] || dcLibref
let dsn = libds.split('.')[1] || ''
// ─── Validate (mirrors the mp_abort checks in loadfile.sas) ─────────────────
@@ -167,6 +167,29 @@ if (!msg) {
}
if (!msg) {
// ─── POST_EDIT_HOOK ──────────────────────────────────────────────────
// Mirrors mpe_loader.sas: the hook runs before the MPE_SUBMIT record is
// written, while LIBREF / DS are still ordinary macro variables, so it
// can re-point the changeset at a different table. The mock eval()s the
// hook's .js counterpart in this scope so it can reassign libref / dsn.
const hookTables = mpeLoadTableData('MPE_TABLES')
const hookReg = (hookTables && hookTables.rows)
? hookTables.rows.find(r => r.libref === libref && r.dsn === dsn)
: null
if (hookReg && hookReg.post_edit_hook) {
const hookSrc = mockHookSource(hookReg.post_edit_hook)
if (hookSrc) {
try {
eval(hookSrc)
} catch (err) {
console.log(
'post_edit_hook ' + hookReg.post_edit_hook + ' failed: ' + err.message
)
}
libds = libref + '.' + dsn
}
}
tableId = makeTableId()
stageSubmission({
dataDir: libDataDir(libref),
+27 -2
View File
@@ -20,8 +20,8 @@ const _sctRow = fetchTable('SASControlTable')[0] || {}
let action = _sctRow.ACTION || 'LOAD'
let message = _sctRow.MESSAGE || ''
let libds = _sctRow.LIBDS || ''
const libref = libds.split('.')[0] || dcLibref
const dsn = libds.split('.')[1] || ''
let libref = libds.split('.')[0] || dcLibref
let dsn = libds.split('.')[1] || ''
// The base table lives in its own library (DC_JSLIB for control tables,
// TESTDATA for demo user tables); the staging dir stays in the control lib.
@@ -56,6 +56,31 @@ if (schema.length > 0) {
})
}
// ─── POST_EDIT_HOOK ──────────────────────────────────────────────────────────
// Mirrors mpe_loader.sas: %mpe_runhook(POST_EDIT_HOOK) runs before the MPE_SUBMIT
// record is written, while LIBREF / DS are still ordinary macro variables - which
// is what lets a hook re-point a changeset at a different table. The mock
// eval()s the hook's .js counterpart in this scope so it can reassign
// libref / dsn / libds; the submit record then names the new base table, exactly
// as the SAS hook does via call symputx.
const hookTables = makeTableLoader(mpeDataDir)('MPE_TABLES')
const hookReg = (hookTables && hookTables.rows)
? hookTables.rows.find(r => r.libref === libref && r.dsn === dsn)
: null
if (hookReg && hookReg.post_edit_hook) {
const hookSrc = mockHookSource(hookReg.post_edit_hook)
if (hookSrc) {
try {
eval(hookSrc)
} catch (err) {
console.log(
'post_edit_hook ' + hookReg.post_edit_hook + ' failed: ' + err.message
)
}
libds = libref + '.' + dsn
}
}
// ─── Stage the data ──────────────────────────────────────────────────────────
// One subfolder per submission (DSID) under the staging directory, so that the
// staged data can sit alongside related artifacts (submit/approval logs,
@@ -0,0 +1,31 @@
// ─────────────────────────────────────────────────────────────────────────────
// Mock of a POST_EDIT_HOOK program
//
// Registered against TESTDATA.DEMO_MIRROR (MPE_TABLES.post_edit_hook).
//
// This file is eval'd by editors/stagedata.js and editors/loadfile.js
// (mirroring how mpe_loader.sas %includes the hook program via %mpe_runhook),
// so it shares the caller's scope: libref, dsn, libds and every dcMockUtils
// function are available directly.
//
// The real hook runs in mpe_loader BEFORE the submit record is written, while
// LIBREF / DS are still ordinary macro variables - which is what lets it
// re-point a changeset at a different table:
//
// data _null_;
// call symputx('libref','TESTDATA');
// call symputx('ds','DEMO_ORDERS');
// run;
//
// (call symputx reaches the calling service's variable because LIBREF/DS are
// not declared %local in mpe_loader; a %let creates a new variable in the
// hook's own scope and is discarded.)
//
// The mock's equivalent is to reassign libref / dsn, so the MPE_SUBMIT record
// names the real table and the approval is raised against it rather than
// against the empty mirror.
// ─────────────────────────────────────────────────────────────────────────────
libref = 'TESTDATA'
dsn = 'DEMO_ORDERS'
console.log('[HOOK DEBUG] demo_mirror_postedit: routing changeset to ' + libref + '.' + dsn)
@@ -0,0 +1,35 @@
// ─────────────────────────────────────────────────────────────────────────────
// Mock of a PRE_EDIT_HOOK program
//
// Registered against TESTDATA.DEMO_MIRROR (MPE_TABLES.pre_edit_hook), which is
// an EMPTY mirror of TESTDATA.DEMO_ORDERS.
//
// This file is eval'd by editors/getdata.js (mirroring how getdata.sas
// %includes the hook program via %mpe_runhook), so it shares the caller's
// scope: visibleRows, visibleColumns, tableReg, requestedLibref and every
// dcMockUtils function (loadTableAnyLib, makeTableLoader, libDataDir, ...) are
// available directly.
//
// The real hook is a .sas program which runs after the filter has been applied
// and the table sorted, with the editor data in work.OUT:
//
// data work.out;
// set TESTDATA.DEMO_ORDERS;
// run;
//
// The mock's equivalent is to replace visibleRows / visibleColumns with the
// rows and columns of the real table, so an empty mirror still shows live data.
// (A real hook that must honour the user's filter has to re-apply it - the
// filter has already been applied to the empty mirror by the time it runs.)
// ─────────────────────────────────────────────────────────────────────────────
const realTable = loadTableAnyLib('DEMO_ORDERS')
if (realTable && realTable.data) {
visibleRows = realTable.data.rows.map((row) => ({ ...row }))
visibleColumns = realTable.data.columns
console.log(
'[HOOK DEBUG] demo_mirror_preedit: loaded ' +
visibleRows.length +
' rows from DEMO_ORDERS'
)
}