@nan0web/ui 1.10.0 → 1.11.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.
Files changed (101) hide show
  1. package/README.md +69 -3
  2. package/package.json +61 -29
  3. package/src/App/Command/DepsCommand.js +3 -4
  4. package/src/Frame/Props.js +12 -18
  5. package/src/InterfaceTemplate/InterfaceTemplate.js +9 -7
  6. package/src/Model/index.js +61 -6
  7. package/src/StdIn.js +2 -6
  8. package/src/cli.js +1 -0
  9. package/src/core/GeneratorRunner.js +67 -7
  10. package/src/core/InputAdapter.js +3 -1
  11. package/src/core/Intent.js +200 -17
  12. package/src/core/Message/Message.js +4 -7
  13. package/src/core/Message/OutputMessage.js +4 -9
  14. package/src/core/StreamEntry.js +20 -28
  15. package/src/core/index.js +1 -0
  16. package/src/domain/Content.js +196 -0
  17. package/src/domain/Document.js +17 -0
  18. package/src/domain/FooterModel.js +37 -19
  19. package/src/domain/HeaderModel.js +47 -21
  20. package/src/domain/HeroModel.js +24 -22
  21. package/src/domain/LayoutModel.js +43 -0
  22. package/src/domain/ModelAsApp.js +46 -0
  23. package/src/domain/SandboxModel.js +19 -16
  24. package/src/domain/app/GalleryCommand.js +53 -0
  25. package/src/domain/app/GalleryRenderIntent.js +77 -0
  26. package/src/domain/app/SnapshotAuditor.js +401 -0
  27. package/src/domain/app/SnapshotRunner.js +264 -0
  28. package/src/domain/app/UIApp.js +78 -0
  29. package/src/domain/components/BreadcrumbModel.js +10 -6
  30. package/src/domain/components/FeatureGridModel.js +62 -0
  31. package/src/domain/components/MarkdownModel.js +24 -0
  32. package/src/domain/components/ShellModel.js +243 -0
  33. package/src/domain/components/TableModel.js +10 -6
  34. package/src/domain/components/ToastModel.js +10 -6
  35. package/src/domain/components/index.js +3 -1
  36. package/src/domain/index.js +14 -4
  37. package/src/index.js +21 -2
  38. package/src/inspect.js +2 -0
  39. package/src/test/ScenarioAdapter.js +59 -0
  40. package/src/test/ScenarioTest.js +51 -0
  41. package/src/test/ScenarioTest.story.js +56 -0
  42. package/src/testing/CrashReporter.js +56 -0
  43. package/src/testing/GalleryGenerator.js +15 -71
  44. package/src/testing/LogicInspector.js +3 -3
  45. package/src/testing/SnapshotRunner.js +22 -0
  46. package/src/testing/SpecAdapter.js +115 -0
  47. package/src/testing/SpecRunner.js +121 -0
  48. package/src/testing/VisualAdapter.js +24 -19
  49. package/src/testing/index.js +5 -1
  50. package/src/testing/verifySnapshot.js +17 -0
  51. package/types/App/Command/DepsCommand.d.ts +0 -2
  52. package/types/Model/index.d.ts +56 -62
  53. package/types/StdIn.d.ts +3 -3
  54. package/types/cli.d.ts +1 -0
  55. package/types/core/GeneratorRunner.d.ts +14 -1
  56. package/types/core/InputAdapter.d.ts +2 -1
  57. package/types/core/Intent.d.ts +209 -31
  58. package/types/core/Message/Message.d.ts +2 -2
  59. package/types/core/Message/OutputMessage.d.ts +0 -2
  60. package/types/core/index.d.ts +1 -0
  61. package/types/domain/Content.d.ts +340 -0
  62. package/types/domain/Document.d.ts +21 -0
  63. package/types/domain/FooterModel.d.ts +22 -12
  64. package/types/domain/HeaderModel.d.ts +36 -13
  65. package/types/domain/HeroModel.d.ts +19 -17
  66. package/types/domain/LayoutModel.d.ts +34 -0
  67. package/types/domain/ModelAsApp.d.ts +23 -0
  68. package/types/domain/SandboxModel.d.ts +10 -0
  69. package/types/domain/app/GalleryCommand.d.ts +55 -0
  70. package/types/domain/app/GalleryRenderIntent.d.ts +31 -0
  71. package/types/domain/app/SnapshotAuditor.d.ts +99 -0
  72. package/types/domain/app/SnapshotRunner.d.ts +45 -0
  73. package/types/domain/app/UIApp.d.ts +60 -0
  74. package/types/domain/components/BreadcrumbModel.d.ts +6 -8
  75. package/types/domain/components/FeatureGridModel.d.ts +50 -0
  76. package/types/domain/components/MarkdownModel.d.ts +19 -0
  77. package/types/domain/components/ShellModel.d.ts +56 -0
  78. package/types/domain/components/TableModel.d.ts +4 -0
  79. package/types/domain/components/ToastModel.d.ts +4 -0
  80. package/types/domain/components/index.d.ts +3 -0
  81. package/types/domain/index.d.ts +10 -4
  82. package/types/index.d.ts +19 -1
  83. package/types/inspect.d.ts +2 -0
  84. package/types/test/ScenarioAdapter.d.ts +43 -0
  85. package/types/test/ScenarioTest.d.ts +24 -0
  86. package/types/test/ScenarioTest.story.d.ts +1 -0
  87. package/types/testing/CrashReporter.d.ts +13 -0
  88. package/types/testing/SnapshotRunner.d.ts +7 -0
  89. package/types/testing/SpecAdapter.d.ts +57 -0
  90. package/types/testing/SpecRunner.d.ts +41 -0
  91. package/types/testing/VisualAdapter.d.ts +0 -6
  92. package/types/testing/index.d.ts +5 -1
  93. package/types/testing/verifySnapshot.d.ts +14 -0
  94. package/src/testing/SnapshotInspector.js +0 -84
  95. package/types/App/Command/Options.d.ts +0 -43
  96. package/types/App/Command/index.d.ts +0 -8
  97. package/types/App/User/Command/Options.d.ts +0 -34
  98. package/types/core/Message/InputMessage.d.ts +0 -71
  99. package/types/domain/components/HeroModel.d.ts +0 -24
  100. package/types/domain/components/ShowcaseAppModel.d.ts +0 -32
  101. 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 yaml from 'js-yaml'
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
- Actions: ['Button', 'Toggle'],
20
- Forms: ['Input', 'Select', 'Slider', 'Autocomplete', 'Color', 'Shadow'],
21
- Data: ['Accordion', 'Card', 'Sortable', 'Table', 'Tree', 'CodeBlock', 'Markdown', 'Badge'],
22
- Feedback: ['Alert', 'Confirm', 'Modal', 'ProgressBar', 'Spinner', 'Toast'],
23
- System: ['LangSelect', 'ThemeToggle'],
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
- for (const [cat, comps] of Object.entries(groups)) {
28
- if (comps.includes(comp)) return cat
29
- }
30
- return 'Other'
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
- generate().catch(console.error)
25
+ SnapshotRunner.generateAndAudit({
26
+ dataDir,
27
+ snapshotsDir,
28
+ getCategory,
29
+ }).catch(console.error)
@@ -29,9 +29,9 @@ export class LogicInspector {
29
29
  intents.push(entry)
30
30
  return { value }
31
31
  },
32
- /** @param {import('../core/Intent.js').LogIntent} i */
33
- log: async (i) => {
34
- intents.push({ type: 'log', level: i.level || 'info', message: i.message })
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,115 @@
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
+
47
+ return { value: step.$value }
48
+ }
49
+
50
+ /**
51
+ * @param {import('../core/Intent.js').ShowIntent} intent
52
+ */
53
+ async show(intent) {
54
+ const step = this.#popExpected('show')
55
+ if (step.show && typeof step.show === 'string') {
56
+ const activeMessage = intent.message
57
+ if (activeMessage && step.show !== '*' && step.show !== '') {
58
+ this.assert.equal(activeMessage, step.show, `Show message mismatch`)
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {import('../core/Intent.js').LogIntent} intent
65
+ */
66
+ async log(intent) {
67
+ const step = this.#popExpected('log')
68
+ if (step.log && typeof step.log === 'string') {
69
+ const activeMessage = intent.message
70
+ if (activeMessage && step.log !== '*' && step.log !== '') {
71
+ this.assert.equal(activeMessage, step.log, `Log message mismatch`)
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {import('../core/Intent.js').ProgressIntent} intent
78
+ */
79
+ async progress(intent) {
80
+ this.#popExpected('progress')
81
+ }
82
+
83
+ /**
84
+ * @param {import('../core/Intent.js').RenderIntent} intent
85
+ */
86
+ async render(intent) {
87
+ const step = this.#popExpected('render')
88
+ const activeComponent = intent.component
89
+ this.assert.equal(activeComponent, step.render, `Render component mismatch. Expected '${step.render}'.`)
90
+ }
91
+
92
+ /**
93
+ * @param {import('../core/Intent.js').AgentIntent} intent
94
+ */
95
+ async agent(intent) {
96
+ const step = this.#popExpected('agent')
97
+ this.assert.equal(step.agent, intent.task, `Agent task mismatch. Expected '${step.agent}'.`)
98
+
99
+ return { success: step.$success !== false, files: step.$files, message: step.$message }
100
+ }
101
+
102
+ /**
103
+ * @param {import('../core/Intent.js').ResultIntent} intent
104
+ */
105
+ async result(intent) {
106
+ // Only pop result if there's a result recorded in the spec stream
107
+ if (this.stream.length > 0 && typeof this.stream[0] === 'object' && 'result' in this.stream[0]) {
108
+ const step = this.#popExpected('result')
109
+ if (step.result !== undefined && step.result !== '*') {
110
+ const actualData = intent?.data ?? intent
111
+ this.assert.deepEqual(actualData, step.result, `Result object deep mismatch`)
112
+ }
113
+ }
114
+ }
115
+ }
@@ -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
- return `[ASK] ${intent.field}: ${intent.input !== undefined ? intent.input : '...'}`
19
+ node = { ask: { field: intent.field, input: intent.input !== undefined ? intent.input : '...' } }
20
+ break
18
21
  case 'progress':
19
- return `[PROGRESS] ${intent.message || ''}`
22
+ node = { progress: { message: intent.message || '' } }
23
+ break
20
24
  case 'log':
21
- return `[LOG ${intent.level?.toUpperCase() || 'INFO'}] ${typeof intent.message === 'object' ? JSON.stringify(intent.message) : intent.message}`
22
- case 'render': {
23
- const { content, ...propsData } = intent.props || {}
24
- const props = Object.entries(propsData)
25
- .map(([k, v]) => ` ${k}="${typeof v === 'object' ? JSON.stringify(v) : v}"`)
26
- .join('\n')
27
-
28
- if (content) {
29
- const attrs = props ? `\n${props}\n` : ' '
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
- return `[UNKNOWN: ${intent.type}] ${JSON.stringify(intent)}`
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
  }
@@ -1,3 +1,7 @@
1
1
  export * from './LogicInspector.js'
2
2
  export * from './VisualAdapter.js'
3
- export * from './SnapshotInspector.js'
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
+ }
@@ -1,8 +1,6 @@
1
1
  export class DepsCommand extends UiMessage {
2
2
  static Body: typeof DepsCommandBody;
3
3
  constructor(input?: {});
4
- /** @type {DepsCommandBody} */
5
- body: DepsCommandBody;
6
4
  }
7
5
  export default DepsCommand;
8
6
  import UiMessage from '../../core/Message/Message.js';