156 lines
7.1 KiB
JavaScript
156 lines
7.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Source-only DOM regressions. No browser, server, build, network or app lifecycle.
|
|
// Usage: node scripts/test-app-starter-component.cjs /path/to/frontend/node_modules [App.vue]
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const { createRequire } = require('node:module')
|
|
const { createHash } = require('node:crypto')
|
|
const deps = path.resolve(process.argv[2] || 'neode-ui/node_modules')
|
|
const requireDep = createRequire(path.join(deps, '.starter-test.cjs'))
|
|
const { JSDOM } = requireDep('jsdom')
|
|
const dom = new JSDOM('<div id="app"></div>', { url: 'https://starter.invalid/' })
|
|
for (const key of ['window', 'document', 'HTMLElement', 'SVGElement', 'Element', 'Node']) {
|
|
global[key] = key === 'window' ? dom.window : dom.window[key]
|
|
}
|
|
const { parse, compileScript } = requireDep('@vue/compiler-sfc')
|
|
const ts = requireDep('typescript')
|
|
const { createApp, nextTick } = requireDep('vue')
|
|
const source = process.argv[3] ? path.resolve(process.argv[3])
|
|
: path.resolve(__dirname, '../examples/archipelago-app-starter/src/App.vue')
|
|
const input = fs.readFileSync(source, 'utf8')
|
|
const parsed = parse(input, { filename: source })
|
|
assert.deepEqual(parsed.errors, [])
|
|
const compiled = compileScript(parsed.descriptor, { id: 'starter-test', inlineTemplate: true })
|
|
const transformed = ts.transpileModule(compiled.content, {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
|
reportDiagnostics: true,
|
|
})
|
|
assert.deepEqual(transformed.diagnostics, [])
|
|
const component = { exports: {} }
|
|
const localRequire = specifier => specifier.startsWith('.')
|
|
? require(path.resolve(path.dirname(source), specifier)) : requireDep(specifier)
|
|
new Function('require', 'module', 'exports', transformed.outputText)(localRequire, component, component.exports)
|
|
const { STORAGE_KEY } = require(path.join(path.dirname(source), 'items.mjs'))
|
|
const root = document.getElementById('app')
|
|
let app
|
|
let passed = 0
|
|
async function mount(raw = null) {
|
|
if (app) app.unmount()
|
|
window.localStorage.clear()
|
|
if (raw !== null) window.localStorage.setItem(STORAGE_KEY, raw)
|
|
app = createApp(component.exports.default)
|
|
app.mount(root)
|
|
await nextTick()
|
|
}
|
|
const button = label => [...root.querySelectorAll('button')].find(node => node.textContent.trim() === label)
|
|
async function click(label) {
|
|
const node = button(label)
|
|
assert.ok(node, `Missing button ${label}`)
|
|
node.focus()
|
|
node.click()
|
|
await nextTick()
|
|
await nextTick()
|
|
}
|
|
async function check(name, run) {
|
|
await run()
|
|
passed += 1
|
|
console.log(`PASS ${name}`)
|
|
}
|
|
async function main() {
|
|
await check('empty add persists and transfers focus from removed empty action', async () => {
|
|
await mount()
|
|
assert.match(root.textContent, /No items yet/)
|
|
await click('Create first item')
|
|
assert.equal(root.querySelectorAll('li').length, 1)
|
|
assert.equal(document.activeElement, button('Add item'))
|
|
assert.equal(JSON.parse(window.localStorage.getItem(STORAGE_KEY))[0].id, 1)
|
|
})
|
|
await check('remount restores actual local data', async () => {
|
|
const saved = window.localStorage.getItem(STORAGE_KEY)
|
|
await mount(saved)
|
|
assert.equal(root.querySelectorAll('li').length, 1)
|
|
assert.match(root.textContent, /Item 1/)
|
|
})
|
|
await check('mounted add action retains keyboard focus', async () => {
|
|
await click('Add item')
|
|
assert.equal(root.querySelectorAll('li').length, 2)
|
|
assert.equal(document.activeElement, button('Add item'))
|
|
})
|
|
await check('invalid data shows alert without resetting; retry restores focus', async () => {
|
|
await mount('broken')
|
|
assert.ok(root.querySelector('[role="alert"]'))
|
|
await click('Try again')
|
|
assert.equal(window.localStorage.getItem(STORAGE_KEY), 'broken')
|
|
assert.equal(document.activeElement, button('Try again'))
|
|
window.localStorage.removeItem(STORAGE_KEY)
|
|
await click('Try again')
|
|
assert.match(root.textContent, /No items yet/)
|
|
assert.equal(document.activeElement, button('Add item'))
|
|
})
|
|
await check('capacity and exhausted ID refusals keep readable records visible', async () => {
|
|
for (const records of [
|
|
Array.from({ length: 1000 }, (_, i) => ({ id: i + 1, name: 'Keep', detail: '' })),
|
|
[{ id: Number.MAX_SAFE_INTEGER, name: 'Last', detail: '' }],
|
|
]) {
|
|
const raw = JSON.stringify(records)
|
|
await mount(raw)
|
|
await click('Add item')
|
|
assert.equal(root.querySelectorAll('li').length, records.length)
|
|
assert.match(root.querySelector('[role="alert"]').textContent, /1000|exhausted/)
|
|
assert.equal(window.localStorage.getItem(STORAGE_KEY), raw)
|
|
assert.equal(document.activeElement, button('Add item'))
|
|
}
|
|
})
|
|
await check('quota failure preserves empty/populated data and mounted action focus', async () => {
|
|
for (const raw of [null, JSON.stringify([{ id: 7, name: 'Keep', detail: 'Existing data' }])]) {
|
|
await mount(raw)
|
|
const proto = Object.getPrototypeOf(window.localStorage)
|
|
const original = proto.setItem
|
|
proto.setItem = () => { throw Error('quota') }
|
|
const action = raw === null ? 'Create first item' : 'Add item'
|
|
try {
|
|
await click(action)
|
|
assert.match(root.querySelector('[role="alert"]').textContent, /quota/)
|
|
assert.equal(root.querySelectorAll('li').length, raw === null ? 0 : 1)
|
|
assert.equal(window.localStorage.getItem(STORAGE_KEY), raw)
|
|
assert.equal(document.activeElement, button(action))
|
|
} finally { proto.setItem = original }
|
|
}
|
|
})
|
|
await check('unfocused activation does not steal focus', async () => {
|
|
await mount()
|
|
button('Refresh').focus()
|
|
button('Create first item').click()
|
|
await nextTick()
|
|
await nextTick()
|
|
assert.equal(document.activeElement, button('Refresh'))
|
|
})
|
|
await check('focus moved before restoration is retained', async () => {
|
|
await mount()
|
|
const trigger = button('Create first item')
|
|
trigger.focus()
|
|
trigger.click()
|
|
button('Refresh').focus()
|
|
await nextTick()
|
|
await nextTick()
|
|
assert.equal(document.activeElement, button('Refresh'))
|
|
})
|
|
await check('blocked storage renders error without uncaught lifecycle error', async () => {
|
|
const descriptor = Object.getOwnPropertyDescriptor(window, 'localStorage')
|
|
if (app) { app.unmount(); app = null }
|
|
Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw Error('blocked') } })
|
|
try {
|
|
app = createApp(component.exports.default)
|
|
app.mount(root)
|
|
await nextTick()
|
|
assert.ok(root.querySelector('[role="alert"]'))
|
|
} finally { Object.defineProperty(window, 'localStorage', descriptor) }
|
|
})
|
|
console.log(JSON.stringify({ tests: passed, app_sha256: createHash('sha256').update(input).digest('hex'), dependencies: Object.fromEntries(['vue', '@vue/compiler-sfc', 'typescript', 'jsdom'].map(name => [name, requireDep(`${name}/package.json`).version])), scope: 'SFC compilation and jsdom runtime only; no typecheck, browser/layout, package build or UAT acceptance' }))
|
|
}
|
|
main().catch(error => { console.error(error); process.exitCode = 1 }).finally(() => {
|
|
if (app) app.unmount()
|
|
dom.window.close()
|
|
})
|