@nan0web/ui 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -3
- package/package.json +65 -29
- package/src/App/Command/DepsCommand.js +3 -4
- package/src/Frame/Props.js +12 -18
- package/src/InterfaceTemplate/InterfaceTemplate.js +9 -7
- package/src/Model/index.js +61 -6
- package/src/StdIn.js +2 -6
- package/src/cli.js +1 -0
- package/src/core/GeneratorRunner.js +67 -7
- package/src/core/InputAdapter.js +22 -5
- package/src/core/Intent.js +230 -18
- package/src/core/Message/Message.js +4 -7
- package/src/core/Message/OutputMessage.js +4 -9
- package/src/core/StreamEntry.js +20 -28
- package/src/core/index.js +4 -0
- package/src/domain/Content.js +198 -0
- package/src/domain/Document.js +25 -0
- package/src/domain/FooterModel.js +37 -19
- package/src/domain/HeaderModel.js +47 -21
- package/src/domain/HeroModel.js +24 -22
- package/src/domain/LayoutModel.js +43 -0
- package/src/domain/ModelAsApp.js +46 -0
- package/src/domain/SandboxModel.js +19 -16
- package/src/domain/app/GalleryCommand.js +53 -0
- package/src/domain/app/GalleryRenderIntent.js +77 -0
- package/src/domain/app/SnapshotAuditor.js +399 -0
- package/src/domain/app/SnapshotRunner.js +264 -0
- package/src/domain/app/UIApp.js +78 -0
- package/src/domain/components/BreadcrumbModel.js +10 -6
- package/src/domain/components/FeatureGridModel.js +62 -0
- package/src/domain/components/MarkdownModel.js +24 -0
- package/src/domain/components/ShellModel.js +243 -0
- package/src/domain/components/TableModel.js +10 -6
- package/src/domain/components/ToastModel.js +10 -6
- package/src/domain/components/index.js +3 -1
- package/src/domain/index.js +14 -4
- package/src/index.js +23 -2
- package/src/inspect.js +2 -0
- package/src/test/ScenarioAdapter.js +59 -0
- package/src/test/ScenarioTest.js +51 -0
- package/src/test/ScenarioTest.story.js +56 -0
- package/src/testing/CrashReporter.js +56 -0
- package/src/testing/GalleryGenerator.js +15 -71
- package/src/testing/LogicInspector.js +4 -4
- package/src/testing/SnapshotRunner.js +22 -0
- package/src/testing/SpecAdapter.js +114 -0
- package/src/testing/SpecRunner.js +121 -0
- package/src/testing/VisualAdapter.js +24 -19
- package/src/testing/index.js +5 -1
- package/src/testing/verifySnapshot.js +17 -0
- package/types/App/Command/DepsCommand.d.ts +0 -2
- package/types/Model/index.d.ts +56 -62
- package/types/StdIn.d.ts +3 -3
- package/types/cli.d.ts +1 -0
- package/types/core/GeneratorRunner.d.ts +14 -1
- package/types/core/InputAdapter.d.ts +50 -6
- package/types/core/Intent.d.ts +280 -32
- package/types/core/Message/Message.d.ts +2 -2
- package/types/core/Message/OutputMessage.d.ts +0 -2
- package/types/core/index.d.ts +4 -0
- package/types/domain/Content.d.ts +344 -0
- package/types/domain/Document.d.ts +40 -0
- package/types/domain/FooterModel.d.ts +22 -12
- package/types/domain/HeaderModel.d.ts +36 -13
- package/types/domain/HeroModel.d.ts +19 -17
- package/types/domain/LayoutModel.d.ts +34 -0
- package/types/domain/ModelAsApp.d.ts +23 -0
- package/types/domain/SandboxModel.d.ts +10 -0
- package/types/domain/app/GalleryCommand.d.ts +55 -0
- package/types/domain/app/GalleryRenderIntent.d.ts +31 -0
- package/types/domain/app/SnapshotAuditor.d.ts +99 -0
- package/types/domain/app/SnapshotRunner.d.ts +45 -0
- package/types/domain/app/UIApp.d.ts +60 -0
- package/types/domain/components/BreadcrumbModel.d.ts +6 -8
- package/types/domain/components/FeatureGridModel.d.ts +50 -0
- package/types/domain/components/MarkdownModel.d.ts +19 -0
- package/types/domain/components/ShellModel.d.ts +56 -0
- package/types/domain/components/TableModel.d.ts +4 -0
- package/types/domain/components/ToastModel.d.ts +4 -0
- package/types/domain/components/index.d.ts +3 -0
- package/types/domain/index.d.ts +10 -4
- package/types/index.d.ts +21 -1
- package/types/inspect.d.ts +2 -0
- package/types/test/ScenarioAdapter.d.ts +43 -0
- package/types/test/ScenarioTest.d.ts +24 -0
- package/types/test/ScenarioTest.story.d.ts +1 -0
- package/types/testing/CrashReporter.d.ts +13 -0
- package/types/testing/SnapshotRunner.d.ts +7 -0
- package/types/testing/SpecAdapter.d.ts +58 -0
- package/types/testing/SpecRunner.d.ts +41 -0
- package/types/testing/VisualAdapter.d.ts +0 -6
- package/types/testing/index.d.ts +5 -1
- package/types/testing/verifySnapshot.d.ts +14 -0
- package/src/testing/SnapshotInspector.js +0 -84
- package/types/App/Command/Options.d.ts +0 -43
- package/types/App/Command/index.d.ts +0 -8
- package/types/App/User/Command/Options.d.ts +0 -34
- package/types/core/Message/InputMessage.d.ts +0 -71
- package/types/domain/components/HeroModel.d.ts +0 -24
- package/types/domain/components/ShowcaseAppModel.d.ts +0 -32
- package/types/testing/SnapshotInspector.d.ts +0 -17
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { runGenerator } from '../core/GeneratorRunner.js'
|
|
2
|
+
import OutputAdapter from '../core/OutputAdapter.js'
|
|
3
|
+
import ScenarioAdapter from './ScenarioAdapter.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic Scenario Test Runner.
|
|
7
|
+
* Orchestrates a model against a predefined scenario, mocking I/O immediately.
|
|
8
|
+
*/
|
|
9
|
+
export class ScenarioTest {
|
|
10
|
+
/**
|
|
11
|
+
* Runs an application model with a specific set of answers.
|
|
12
|
+
*
|
|
13
|
+
* @param {typeof import('../domain/ModelAsApp.js').ModelAsApp} AppClass
|
|
14
|
+
* @param {Array<{field: string, value: any, cancelled?: boolean}>} scenario
|
|
15
|
+
* @param {any} [appData={}]
|
|
16
|
+
* @returns {Promise<{ value: any, intents: any[], error?: Error | undefined }>}
|
|
17
|
+
*/
|
|
18
|
+
static async run(AppClass, scenario = [], appData = {}) {
|
|
19
|
+
const inputAdapter = new ScenarioAdapter(scenario)
|
|
20
|
+
|
|
21
|
+
const app = new AppClass(appData, { adapter: inputAdapter })
|
|
22
|
+
|
|
23
|
+
let value
|
|
24
|
+
let error
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
// runGenerator executes the Intents returned by AppClass.run()
|
|
28
|
+
value = await runGenerator(
|
|
29
|
+
app.run(),
|
|
30
|
+
{
|
|
31
|
+
ask: inputAdapter.askIntent.bind(inputAdapter),
|
|
32
|
+
progress: inputAdapter.progressIntent.bind(inputAdapter),
|
|
33
|
+
show: inputAdapter.showIntent.bind(inputAdapter),
|
|
34
|
+
render: inputAdapter.renderIntent.bind(inputAdapter),
|
|
35
|
+
result: inputAdapter.resultIntent.bind(inputAdapter),
|
|
36
|
+
},
|
|
37
|
+
{ timeoutMs: 3333 },
|
|
38
|
+
)
|
|
39
|
+
} catch (err) {
|
|
40
|
+
error = /** @type {Error} */ (err)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
value,
|
|
45
|
+
intents: inputAdapter.intents,
|
|
46
|
+
error,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default ScenarioTest
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import DB from '@nan0web/db-fs'
|
|
4
|
+
import { SpecRunner } from '../testing/index.js'
|
|
5
|
+
import { ModelAsApp, ask, show, result } from '../index.js'
|
|
6
|
+
|
|
7
|
+
const db = new DB({ root: 'tests/uk' })
|
|
8
|
+
|
|
9
|
+
async function loadStory(name) {
|
|
10
|
+
const doc = await db.loadDocument(`${name}.nan0`)
|
|
11
|
+
return doc
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('Workflow Story: Shopping Cart Purchase', () => {
|
|
15
|
+
class ShoppingCartApp extends ModelAsApp {
|
|
16
|
+
async *run() {
|
|
17
|
+
const { value: product } = yield ask('product', { help: 'Select product' })
|
|
18
|
+
if (product === 'laptop') {
|
|
19
|
+
yield show('Good choice!')
|
|
20
|
+
}
|
|
21
|
+
const { value: confirm } = yield ask('confirm', { help: 'Confirm purchase?' })
|
|
22
|
+
return result({ product: product, confirm: confirm })
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
it('should successfully buy a laptop and show a message via SpecRunner', async () => {
|
|
27
|
+
const doc = await loadStory('shopping.story')
|
|
28
|
+
assert.ok(Array.isArray(doc.story), 'Story must be an array')
|
|
29
|
+
|
|
30
|
+
await assert.doesNotReject(() => SpecRunner.execute(doc.story, { ShoppingCartApp }))
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('Workflow Story: Validated Input Form', () => {
|
|
35
|
+
class ValidatedApp extends ModelAsApp {
|
|
36
|
+
async *run() {
|
|
37
|
+
const { value: code } = yield ask('code', { help: 'Enter code', required: true })
|
|
38
|
+
if (!code) throw new Error('Code is mandatory')
|
|
39
|
+
return result({ code: code })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it('should successfully process a valid code via SpecRunner', async () => {
|
|
44
|
+
const doc = await loadStory('validated.story')
|
|
45
|
+
assert.ok(Array.isArray(doc.story), 'Story must be an array')
|
|
46
|
+
|
|
47
|
+
await assert.doesNotReject(() => SpecRunner.execute(doc.story, { ValidatedApp }))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should fail if user provides empty input (Node.js Test)', async () => {
|
|
51
|
+
// Testing error conditions are better suited as explicit scenarios or node:test
|
|
52
|
+
const stream = [{ ValidatedApp: {} }, { ask: 'code', $value: '' }]
|
|
53
|
+
|
|
54
|
+
await assert.rejects(SpecRunner.execute(stream, { ValidatedApp }), /Code is mandatory/)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CrashReporter.js — Nan0Spec serialization.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transforms an execution trace from GeneratorRunner into a strict Nan0Spec model
|
|
7
|
+
* ready to be saved as a .nan0 file for Crash Reporting and Integration Tests.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} appName The name of the root model/app (e.g. 'ShoppingCartApp').
|
|
10
|
+
* @param {object} appData The initial data provided to the app.
|
|
11
|
+
* @param {import('../core/Intent.js').Intent[]} trace The array of intents executed.
|
|
12
|
+
* @returns {Array<object>} The serializable Nan0Spec array.
|
|
13
|
+
*/
|
|
14
|
+
export function buildNan0SpecFromTrace(appName, appData, trace) {
|
|
15
|
+
const spec = []
|
|
16
|
+
|
|
17
|
+
// 1. Initial State
|
|
18
|
+
spec.push({
|
|
19
|
+
[appName]: appData ? JSON.parse(JSON.stringify(appData)) : {},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** @type {Record<string, string>} Mapping from intent type to its primary payload field in Nan0Spec */
|
|
23
|
+
const PAYLOAD_MAP = {
|
|
24
|
+
ask: 'field',
|
|
25
|
+
show: 'message',
|
|
26
|
+
log: 'message',
|
|
27
|
+
progress: 'message',
|
|
28
|
+
render: 'component',
|
|
29
|
+
agent: 'task'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 2. Map trace
|
|
33
|
+
for (const intent of trace) {
|
|
34
|
+
if (intent.type === 'result') {
|
|
35
|
+
spec.push({ result: intent.data || null })
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const key = PAYLOAD_MAP[intent.type]
|
|
40
|
+
if (!key) continue
|
|
41
|
+
|
|
42
|
+
// Create the primary step { type: payload }
|
|
43
|
+
const step = { [intent.type]: intent[key] }
|
|
44
|
+
|
|
45
|
+
// Automatically include all metadata/response fields (starting with $)
|
|
46
|
+
for (const [k, v] of Object.entries(intent)) {
|
|
47
|
+
if (k.startsWith('$') && v !== undefined) {
|
|
48
|
+
step[k] = v
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
spec.push(step)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return spec
|
|
56
|
+
}
|
|
@@ -1,85 +1,29 @@
|
|
|
1
|
-
import fs from 'fs'
|
|
2
1
|
import path from 'path'
|
|
3
2
|
import { fileURLToPath } from 'url'
|
|
4
|
-
import
|
|
5
|
-
import { LogicInspector } from './LogicInspector.js'
|
|
6
|
-
import { VisualAdapter } from './VisualAdapter.js'
|
|
7
|
-
import * as Models from '../domain/index.js'
|
|
3
|
+
import { SnapshotRunner } from './SnapshotRunner.js'
|
|
8
4
|
|
|
9
5
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
10
6
|
const rootDir = path.resolve(__dirname, '../../')
|
|
11
7
|
const dataDir = path.resolve(rootDir, 'docs/data')
|
|
12
8
|
const snapshotsDir = path.resolve(rootDir, 'snapshots/core')
|
|
13
9
|
|
|
14
|
-
// Clean before generation
|
|
15
|
-
if (fs.existsSync(snapshotsDir)) fs.rmSync(snapshotsDir, { recursive: true, force: true })
|
|
16
|
-
fs.mkdirSync(snapshotsDir, { recursive: true })
|
|
17
|
-
|
|
18
10
|
const groups = {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
11
|
+
Actions: ['Button', 'Toggle'],
|
|
12
|
+
Forms: ['Input', 'Select', 'Slider', 'Autocomplete', 'Color', 'Shadow'],
|
|
13
|
+
Data: ['Accordion', 'Card', 'Sortable', 'Table', 'Tree', 'CodeBlock', 'Markdown', 'Badge'],
|
|
14
|
+
Feedback: ['Alert', 'Confirm', 'Modal', 'ProgressBar', 'Spinner', 'Toast'],
|
|
15
|
+
System: ['LangSelect', 'ThemeToggle'],
|
|
24
16
|
}
|
|
25
17
|
|
|
26
18
|
function getCategory(comp) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function generate() {
|
|
34
|
-
const langs = fs.readdirSync(dataDir).filter(d => fs.statSync(path.join(dataDir, d)).isDirectory() && d !== '_')
|
|
35
|
-
|
|
36
|
-
for (const lang of langs) {
|
|
37
|
-
const langDir = path.join(dataDir, lang)
|
|
38
|
-
const components = fs.readdirSync(langDir).filter(f => f.endsWith('.yaml'))
|
|
39
|
-
|
|
40
|
-
for (const file of components) {
|
|
41
|
-
const compName = file.replace('.yaml', '')
|
|
42
|
-
const category = getCategory(compName)
|
|
43
|
-
const text = fs.readFileSync(path.join(langDir, file), 'utf-8')
|
|
44
|
-
const data = yaml.load(text)
|
|
45
|
-
|
|
46
|
-
// Variations are in data.content
|
|
47
|
-
const variations = data.content || []
|
|
48
|
-
|
|
49
|
-
for (let i = 0; i < variations.length; i++) {
|
|
50
|
-
const varData = variations[i][compName] || variations[i]
|
|
51
|
-
|
|
52
|
-
// Get variation name from 'content', 'title', 'message', or fallback to index
|
|
53
|
-
let varName = varData.content || varData.title || varData.message || `var${i + 1}`
|
|
54
|
-
if (typeof varName !== 'string') varName = `var${i + 1}`
|
|
55
|
-
|
|
56
|
-
// Clean filename: allow Ukrainian, but replace spaces/special chars with single underscore
|
|
57
|
-
const safeVarName = varName
|
|
58
|
-
.trim()
|
|
59
|
-
.toLowerCase()
|
|
60
|
-
.replace(/[./\\:]/g, '_') // Replace paths/dots
|
|
61
|
-
.replace(/\s+/g, '_') // Replace spaces
|
|
62
|
-
.replace(/_{2,}/g, '_') // No double underscores
|
|
63
|
-
.slice(0, 50) // Max length
|
|
64
|
-
|
|
65
|
-
// Logic Capture
|
|
66
|
-
/** @type {() => AsyncGenerator<import('../core/Intent.js').Intent, import('../core/Intent.js').ResultIntent, any>} */
|
|
67
|
-
const modelStream = async function* () {
|
|
68
|
-
yield { type: 'render', component: `ui-${compName.toLowerCase()}`, props: varData }
|
|
69
|
-
return { type: 'result', data: { ok: true } }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const intents = await LogicInspector.capture(modelStream())
|
|
73
|
-
const snapshot = intents.map(it => VisualAdapter.render(it)).join('\n')
|
|
74
|
-
|
|
75
|
-
const outPath = path.join(snapshotsDir, lang, category, compName)
|
|
76
|
-
if (!fs.existsSync(outPath)) fs.mkdirSync(outPath, { recursive: true })
|
|
77
|
-
|
|
78
|
-
fs.writeFileSync(path.join(outPath, `${safeVarName}.txt`), snapshot)
|
|
79
|
-
console.log(`📸 Generated snapshot for ${lang}/${category}/${compName}/${safeVarName}`)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
19
|
+
for (const [cat, comps] of Object.entries(groups)) {
|
|
20
|
+
if (comps.includes(comp)) return cat
|
|
21
|
+
}
|
|
22
|
+
return 'Other'
|
|
83
23
|
}
|
|
84
24
|
|
|
85
|
-
|
|
25
|
+
SnapshotRunner.generateAndAudit({
|
|
26
|
+
dataDir,
|
|
27
|
+
snapshotsDir,
|
|
28
|
+
getCategory,
|
|
29
|
+
}).catch(console.error)
|
|
@@ -27,11 +27,11 @@ export class LogicInspector {
|
|
|
27
27
|
const value = resolvedInputs[inputIdx++]
|
|
28
28
|
const entry = { type: 'ask', field: i.field, schema: i.schema, input: value }
|
|
29
29
|
intents.push(entry)
|
|
30
|
-
return { value }
|
|
30
|
+
return { value, cancelled: false }
|
|
31
31
|
},
|
|
32
|
-
/** @param {import('../core/Intent.js').
|
|
33
|
-
|
|
34
|
-
intents.push({ type: '
|
|
32
|
+
/** @param {import('../core/Intent.js').ShowIntent} i */
|
|
33
|
+
show: async (i) => {
|
|
34
|
+
intents.push({ type: 'show', level: i.level || 'info', message: i.message })
|
|
35
35
|
},
|
|
36
36
|
/** @param {import('../core/Intent.js').ProgressIntent} i */
|
|
37
37
|
progress: async (i) => {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SnapshotRunner as Runner } from '../domain/app/SnapshotRunner.js'
|
|
2
|
+
import DBFS from '@nan0web/db-fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Legacy bridge for SnapshotRunner.
|
|
6
|
+
* @deprecated Use SnapshotRunner model from domain/app
|
|
7
|
+
*/
|
|
8
|
+
export class SnapshotRunner {
|
|
9
|
+
static async generateAndAudit(options) {
|
|
10
|
+
const db = options.db || new DBFS({ root: options.dataDir })
|
|
11
|
+
const runner = new Runner(options, { db })
|
|
12
|
+
if (options.getCategory) runner.getCategory = options.getCategory
|
|
13
|
+
if (options.createModelStream) runner.createModelStream = options.createModelStream
|
|
14
|
+
|
|
15
|
+
const gen = runner.run()
|
|
16
|
+
let res = await gen.next()
|
|
17
|
+
while (!res.done) {
|
|
18
|
+
res = await gen.next()
|
|
19
|
+
}
|
|
20
|
+
return res.value
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} SpecAdapterOptions
|
|
5
|
+
* @property {typeof import('node:assert/strict')} [assert] Custom assertion library (falls back to node:assert in Node runtime)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class SpecAdapter {
|
|
9
|
+
/** @type {Array<object>} */
|
|
10
|
+
stream
|
|
11
|
+
/** @type {typeof import('node:assert/strict')} */
|
|
12
|
+
assert
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Array<object>} stream The remaining nan0 array without the first element.
|
|
16
|
+
* @param {SpecAdapterOptions} [options={}]
|
|
17
|
+
*/
|
|
18
|
+
constructor(stream, options = {}) {
|
|
19
|
+
this.stream = stream
|
|
20
|
+
this.assert = options.assert || assert
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Helper to get the next step and assert type match.
|
|
25
|
+
* @param {string} intentType
|
|
26
|
+
*/
|
|
27
|
+
#popExpected(intentType) {
|
|
28
|
+
const step = this.stream.shift()
|
|
29
|
+
if (!step) {
|
|
30
|
+
this.assert.fail(`Model yielded '${intentType}' intent, but Nan0Spec stream is exhausted (no more steps expected).`)
|
|
31
|
+
}
|
|
32
|
+
if (!(intentType in step)) {
|
|
33
|
+
// Find what step type it actually is
|
|
34
|
+
const actualType = Object.keys(step).find(k => !k.startsWith('$'))
|
|
35
|
+
this.assert.fail(`Strict mismatch: Model yielded '${intentType}', but Nan0Spec stream expected '${actualType}'. Trace: ${JSON.stringify(step)}`)
|
|
36
|
+
}
|
|
37
|
+
return step
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {import('../core/Intent.js').AskIntent} intent
|
|
42
|
+
*/
|
|
43
|
+
async ask(intent) {
|
|
44
|
+
const step = this.#popExpected('ask')
|
|
45
|
+
this.assert.equal(step.ask, intent.field, `Field mismatch on ask. Expected '${step.ask}', got '${intent.field}'`)
|
|
46
|
+
return { value: step.$value, cancelled: false }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {import('../core/Intent.js').ShowIntent} intent
|
|
51
|
+
*/
|
|
52
|
+
async show(intent) {
|
|
53
|
+
const step = this.#popExpected('show')
|
|
54
|
+
if (step.show && typeof step.show === 'string') {
|
|
55
|
+
const activeMessage = intent.message
|
|
56
|
+
if (activeMessage && step.show !== '*' && step.show !== '') {
|
|
57
|
+
this.assert.equal(activeMessage, step.show, `Show message mismatch`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {import('../core/Intent.js').LogIntent} intent
|
|
64
|
+
*/
|
|
65
|
+
async log(intent) {
|
|
66
|
+
const step = this.#popExpected('log')
|
|
67
|
+
if (step.log && typeof step.log === 'string') {
|
|
68
|
+
const activeMessage = intent.message
|
|
69
|
+
if (activeMessage && step.log !== '*' && step.log !== '') {
|
|
70
|
+
this.assert.equal(activeMessage, step.log, `Log message mismatch`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {import('../core/Intent.js').ProgressIntent} intent
|
|
77
|
+
*/
|
|
78
|
+
async progress(intent) {
|
|
79
|
+
this.#popExpected('progress')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {import('../core/Intent.js').RenderIntent} intent
|
|
84
|
+
*/
|
|
85
|
+
async render(intent) {
|
|
86
|
+
const step = this.#popExpected('render')
|
|
87
|
+
const activeComponent = intent.component
|
|
88
|
+
this.assert.equal(activeComponent, step.render, `Render component mismatch. Expected '${step.render}'.`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {import('../core/Intent.js').AgentIntent} intent
|
|
93
|
+
*/
|
|
94
|
+
async agent(intent) {
|
|
95
|
+
const step = this.#popExpected('agent')
|
|
96
|
+
this.assert.equal(step.agent, intent.task, `Agent task mismatch. Expected '${step.agent}'.`)
|
|
97
|
+
|
|
98
|
+
return { success: step.$success !== false, files: step.$files, message: step.$message }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {import('../core/Intent.js').ResultIntent} intent
|
|
103
|
+
*/
|
|
104
|
+
async result(intent) {
|
|
105
|
+
// Only pop result if there's a result recorded in the spec stream
|
|
106
|
+
if (this.stream.length > 0 && typeof this.stream[0] === 'object' && 'result' in this.stream[0]) {
|
|
107
|
+
const step = this.#popExpected('result')
|
|
108
|
+
if (step.result !== undefined && step.result !== '*') {
|
|
109
|
+
const actualData = intent?.data ?? intent
|
|
110
|
+
this.assert.deepEqual(actualData, step.result, `Result object deep mismatch`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { runGenerator } from '../core/GeneratorRunner.js'
|
|
3
|
+
import { ModelAsApp } from '../domain/ModelAsApp.js'
|
|
4
|
+
import { result, progress } from '../core/Intent.js'
|
|
5
|
+
import { SpecAdapter } from './SpecAdapter.js'
|
|
6
|
+
|
|
7
|
+
export class SpecRunner extends ModelAsApp {
|
|
8
|
+
static stream = { help: 'The .nan0 intent stream array', default: [] }
|
|
9
|
+
static registry = { help: 'A registry of Model Classes that can be mounted', default: {} }
|
|
10
|
+
static UI = {
|
|
11
|
+
invalidStream: 'Invalid Nan0Spec: stream must be a non-empty array',
|
|
12
|
+
invalidFirstStep: 'Invalid Nan0Spec: first step missing or invalid',
|
|
13
|
+
missingAppName:
|
|
14
|
+
'Invalid Nan0Spec: first step must define the AppName key (e.g. ShoppingCartApp)',
|
|
15
|
+
appNotFound:
|
|
16
|
+
"SpecRunner: AppName '{app}' not found in provided registry. Did you forget to import it?",
|
|
17
|
+
invalidGenerator: "SpecRunner: Model '{app}' does not have a valid run() async generator",
|
|
18
|
+
running: 'Running {app}...',
|
|
19
|
+
unhandledSteps: 'Model finished execution, but stream still has {count} unhandled steps',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @type {typeof import('node:assert/strict')} */
|
|
23
|
+
#assert
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {Partial<SpecRunner>} [data={}]
|
|
27
|
+
* @param {Partial<import('../index.js').ModelAsAppOptions> & { assert?: typeof import('node:assert/strict') }} [options={}]
|
|
28
|
+
*/
|
|
29
|
+
constructor(data = {}, options = {}) {
|
|
30
|
+
super(data, /** @type {import('../index.js').ModelAsAppOptions} */ (options))
|
|
31
|
+
this.#assert = options.assert || assert
|
|
32
|
+
/** @type {Array<object>} The Nan0Spec stream */
|
|
33
|
+
this.stream
|
|
34
|
+
/** @type {Record<string, any>} The registry of Model Classes */
|
|
35
|
+
this.registry
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @throws {Error}
|
|
40
|
+
* @returns {AsyncGenerator<import('../core/Intent.js').Intent, import('../core/Intent.js').ResultIntent, any>}
|
|
41
|
+
*/
|
|
42
|
+
async *run() {
|
|
43
|
+
const { t } = this._
|
|
44
|
+
const stream = this.stream
|
|
45
|
+
const registry = this.registry
|
|
46
|
+
|
|
47
|
+
if (!Array.isArray(stream) || stream.length === 0) {
|
|
48
|
+
throw new Error(t(SpecRunner.UI.invalidStream))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Clone the stream so we can shift without destroying the original reference
|
|
52
|
+
const localStream = [...stream]
|
|
53
|
+
|
|
54
|
+
const firstStep = localStream.shift()
|
|
55
|
+
if (!firstStep || typeof firstStep !== 'object') {
|
|
56
|
+
throw new Error(t(SpecRunner.UI.invalidFirstStep))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const appName = Object.keys(firstStep).find((k) => !k.startsWith('$'))
|
|
60
|
+
if (!appName) {
|
|
61
|
+
throw new Error(t(SpecRunner.UI.missingAppName))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ModelClass = /** @type {any} */ (registry[appName])
|
|
65
|
+
if (!ModelClass) {
|
|
66
|
+
throw new Error(t(SpecRunner.UI.appNotFound, { app: appName }))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const appData = firstStep[appName] || {}
|
|
70
|
+
const adapter = new SpecAdapter(localStream, { assert: this.#assert })
|
|
71
|
+
|
|
72
|
+
// Create the model, passing our own context so it inherits translations etc
|
|
73
|
+
const model = new ModelClass(appData, this._)
|
|
74
|
+
const generator = typeof model.run === 'function' ? model.run() : null
|
|
75
|
+
|
|
76
|
+
if (!generator || typeof generator.next !== 'function') {
|
|
77
|
+
throw new Error(t(SpecRunner.UI.invalidGenerator, { app: appName }))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
yield progress(t(SpecRunner.UI.running, { app: appName }))
|
|
82
|
+
await runGenerator(generator, {
|
|
83
|
+
ask: adapter.ask.bind(adapter),
|
|
84
|
+
show: adapter.show.bind(adapter),
|
|
85
|
+
log: adapter.log.bind(adapter),
|
|
86
|
+
progress: adapter.progress.bind(adapter),
|
|
87
|
+
render: adapter.render.bind(adapter),
|
|
88
|
+
agent: adapter.agent.bind(adapter),
|
|
89
|
+
result: adapter.result.bind(adapter),
|
|
90
|
+
})
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// Bubbling assertion errors correctly
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The stream must be fully consumed
|
|
97
|
+
if (localStream.length > 0) {
|
|
98
|
+
assert.fail(t(SpecRunner.UI.unhandledSteps, { count: localStream.length }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result({ success: true, appName })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run a Nan0Spec sequence programmatically (for unit tests).
|
|
106
|
+
*
|
|
107
|
+
* @param {Array<object>} stream The .nan0 intent stream array
|
|
108
|
+
* @param {Record<string, any>} registry A registry of Model Classes that can be mounted
|
|
109
|
+
* @param {typeof import('node:assert/strict')} [asserter] Custom assertion library
|
|
110
|
+
*/
|
|
111
|
+
static async execute(stream, registry, asserter = assert) {
|
|
112
|
+
const runner = new SpecRunner({ stream, registry }, { assert: asserter })
|
|
113
|
+
const it = runner.run()
|
|
114
|
+
while (true) {
|
|
115
|
+
const { value, done } = await it.next()
|
|
116
|
+
if (done) return value?.data
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default SpecRunner
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* VisualAdapter (Base)
|
|
3
3
|
*
|
|
4
4
|
* Базовий клас для візуальної трансформації інтенцій OLMUI.
|
|
5
|
-
* Використовувана у @nan0web/ui як фундамент для спеціалізованих рендерерів.
|
|
6
5
|
*/
|
|
6
|
+
import { NaN0 } from '@nan0web/types'
|
|
7
|
+
|
|
7
8
|
export class VisualAdapter {
|
|
8
9
|
/**
|
|
9
10
|
* Конвертує одну інтенцію у просте текстове представлення.
|
|
@@ -12,30 +13,34 @@ export class VisualAdapter {
|
|
|
12
13
|
* @returns {string} Raw description
|
|
13
14
|
*/
|
|
14
15
|
static render(intent, t = (k) => k) {
|
|
16
|
+
let node;
|
|
15
17
|
switch (intent.type) {
|
|
16
18
|
case 'ask':
|
|
17
|
-
|
|
19
|
+
node = { ask: { field: intent.field, input: intent.input !== undefined ? intent.input : '...' } }
|
|
20
|
+
break
|
|
18
21
|
case 'progress':
|
|
19
|
-
|
|
22
|
+
node = { progress: { message: intent.message || '' } }
|
|
23
|
+
break
|
|
20
24
|
case 'log':
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return `[RENDER] <${intent.component}${attrs}>${content}</${intent.component}>`
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return `[RENDER] <${intent.component}${props ? '\n' + props + '\n' : ''}>`
|
|
25
|
+
node = { log: { level: intent.level?.toUpperCase() || 'INFO', message: intent.message } }
|
|
26
|
+
break
|
|
27
|
+
case 'render':
|
|
28
|
+
node = { render: { [intent.component]: intent.props || {} } }
|
|
29
|
+
break
|
|
30
|
+
case 'result': {
|
|
31
|
+
const data = intent.data || {}
|
|
32
|
+
node = { result: (typeof data === 'object' && data !== null && Object.keys(data).length === 0) ? {} : data }
|
|
33
|
+
break
|
|
34
34
|
}
|
|
35
|
-
case 'result':
|
|
36
|
-
return `[RESULT] ${JSON.stringify(intent.data)}`
|
|
37
35
|
default:
|
|
38
|
-
|
|
36
|
+
node = { [intent.type || 'unknown']: intent }
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
return NaN0.stringify([node]).trim()
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return `- error: ${JSON.stringify(node)}`
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
}
|
package/src/testing/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export * from './LogicInspector.js'
|
|
2
2
|
export * from './VisualAdapter.js'
|
|
3
|
-
export * from '
|
|
3
|
+
export * from '../domain/app/SnapshotAuditor.js'
|
|
4
|
+
export * from './verifySnapshot.js'
|
|
5
|
+
export { SpecRunner } from './SpecRunner.js'
|
|
6
|
+
export { SpecAdapter } from './SpecAdapter.js'
|
|
7
|
+
export * from './CrashReporter.js'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saves a logical JSONL snapshot sequence
|
|
3
|
+
* @param {Object} options
|
|
4
|
+
* @param {string} options.name Snapshot file name (e.g. 'uk/ExploreCatalog.jsonl')
|
|
5
|
+
* @param {Array<Object>} options.data Array of intents to serialize
|
|
6
|
+
* @param {typeof import('node:fs/promises')} [options.fs] Injected filesystem
|
|
7
|
+
* @param {typeof import('node:path')} [options.path] Injected path module
|
|
8
|
+
*/
|
|
9
|
+
export async function verifySnapshot({ name, data, fs, path }) {
|
|
10
|
+
if (!fs) fs = await import('node:fs/promises')
|
|
11
|
+
if (!path) path = await import('node:path')
|
|
12
|
+
|
|
13
|
+
const outPath = path.resolve(process.cwd(), 'snapshots/jsonl', name)
|
|
14
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true })
|
|
15
|
+
const content = data.map((i) => JSON.stringify(i)).join('\n')
|
|
16
|
+
await fs.writeFile(outPath, content + '\n')
|
|
17
|
+
}
|