@nan0web/ui 1.9.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 (108) hide show
  1. package/README.md +97 -12
  2. package/package.json +54 -25
  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 +86 -2
  7. package/src/StdIn.js +2 -6
  8. package/src/cli.js +1 -0
  9. package/src/core/Form/Form.js +8 -7
  10. package/src/core/Form/Message.js +1 -1
  11. package/src/core/GeneratorRunner.js +77 -7
  12. package/src/core/InputAdapter.js +3 -1
  13. package/src/core/Intent.js +214 -16
  14. package/src/core/IntentErrorModel.js +6 -1
  15. package/src/core/Message/Message.js +4 -7
  16. package/src/core/Message/OutputMessage.js +4 -9
  17. package/src/core/Stream.js +16 -5
  18. package/src/core/StreamEntry.js +20 -28
  19. package/src/core/index.js +2 -1
  20. package/src/domain/Content.js +196 -0
  21. package/src/domain/Document.js +17 -0
  22. package/src/domain/FooterModel.js +37 -19
  23. package/src/domain/HeaderModel.js +47 -21
  24. package/src/domain/HeroModel.js +24 -22
  25. package/src/domain/LayoutModel.js +43 -0
  26. package/src/domain/ModelAsApp.js +46 -0
  27. package/src/domain/SandboxModel.js +19 -16
  28. package/src/domain/app/GalleryCommand.js +53 -0
  29. package/src/domain/app/GalleryRenderIntent.js +77 -0
  30. package/src/domain/app/SnapshotAuditor.js +401 -0
  31. package/src/domain/app/SnapshotRunner.js +264 -0
  32. package/src/domain/app/UIApp.js +78 -0
  33. package/src/domain/components/BreadcrumbModel.js +10 -6
  34. package/src/domain/components/FeatureGridModel.js +62 -0
  35. package/src/domain/components/MarkdownModel.js +24 -0
  36. package/src/domain/components/ShellModel.js +243 -0
  37. package/src/domain/components/TableModel.js +10 -6
  38. package/src/domain/components/ToastModel.js +10 -6
  39. package/src/domain/components/index.js +3 -1
  40. package/src/domain/index.js +14 -4
  41. package/src/index.js +21 -2
  42. package/src/inspect.js +2 -0
  43. package/src/test/ScenarioAdapter.js +59 -0
  44. package/src/test/ScenarioTest.js +51 -0
  45. package/src/test/ScenarioTest.story.js +56 -0
  46. package/src/testing/CrashReporter.js +56 -0
  47. package/src/testing/GalleryGenerator.js +29 -0
  48. package/src/testing/LogicInspector.js +55 -0
  49. package/src/testing/SnapshotRunner.js +22 -0
  50. package/src/testing/SpecAdapter.js +115 -0
  51. package/src/testing/SpecRunner.js +121 -0
  52. package/src/testing/VisualAdapter.js +46 -0
  53. package/src/testing/index.js +7 -0
  54. package/src/testing/verifySnapshot.js +17 -0
  55. package/types/App/Command/DepsCommand.d.ts +0 -2
  56. package/types/Model/index.d.ts +56 -4
  57. package/types/StdIn.d.ts +3 -3
  58. package/types/cli.d.ts +1 -0
  59. package/types/core/Form/Form.d.ts +2 -2
  60. package/types/core/GeneratorRunner.d.ts +18 -1
  61. package/types/core/InputAdapter.d.ts +2 -1
  62. package/types/core/Intent.d.ts +232 -26
  63. package/types/core/IntentErrorModel.d.ts +4 -0
  64. package/types/core/Message/Message.d.ts +2 -2
  65. package/types/core/Message/OutputMessage.d.ts +0 -2
  66. package/types/core/index.d.ts +2 -1
  67. package/types/domain/Content.d.ts +340 -0
  68. package/types/domain/Document.d.ts +21 -0
  69. package/types/domain/FooterModel.d.ts +22 -12
  70. package/types/domain/HeaderModel.d.ts +36 -13
  71. package/types/domain/HeroModel.d.ts +19 -17
  72. package/types/domain/LayoutModel.d.ts +34 -0
  73. package/types/domain/ModelAsApp.d.ts +23 -0
  74. package/types/domain/SandboxModel.d.ts +10 -0
  75. package/types/domain/app/GalleryCommand.d.ts +55 -0
  76. package/types/domain/app/GalleryRenderIntent.d.ts +31 -0
  77. package/types/domain/app/SnapshotAuditor.d.ts +99 -0
  78. package/types/domain/app/SnapshotRunner.d.ts +45 -0
  79. package/types/domain/app/UIApp.d.ts +60 -0
  80. package/types/domain/components/BreadcrumbModel.d.ts +6 -8
  81. package/types/domain/components/FeatureGridModel.d.ts +50 -0
  82. package/types/domain/components/MarkdownModel.d.ts +19 -0
  83. package/types/domain/components/ShellModel.d.ts +56 -0
  84. package/types/domain/components/TableModel.d.ts +4 -0
  85. package/types/domain/components/ToastModel.d.ts +4 -0
  86. package/types/domain/components/index.d.ts +3 -0
  87. package/types/domain/index.d.ts +10 -4
  88. package/types/index.d.ts +19 -1
  89. package/types/inspect.d.ts +2 -0
  90. package/types/test/ScenarioAdapter.d.ts +43 -0
  91. package/types/test/ScenarioTest.d.ts +24 -0
  92. package/types/test/ScenarioTest.story.d.ts +1 -0
  93. package/types/testing/CrashReporter.d.ts +13 -0
  94. package/types/testing/GalleryGenerator.d.ts +1 -0
  95. package/types/testing/LogicInspector.d.ts +22 -0
  96. package/types/testing/SnapshotRunner.d.ts +7 -0
  97. package/types/testing/SpecAdapter.d.ts +57 -0
  98. package/types/testing/SpecRunner.d.ts +41 -0
  99. package/types/testing/VisualAdapter.d.ts +9 -0
  100. package/types/testing/index.d.ts +7 -0
  101. package/types/testing/verifySnapshot.d.ts +14 -0
  102. package/src/README.md.js +0 -436
  103. package/types/App/Command/Options.d.ts +0 -43
  104. package/types/App/Command/index.d.ts +0 -8
  105. package/types/App/User/Command/Options.d.ts +0 -34
  106. package/types/core/Message/InputMessage.d.ts +0 -71
  107. package/types/domain/components/HeroModel.d.ts +0 -24
  108. package/types/domain/components/ShowcaseAppModel.d.ts +0 -32
@@ -0,0 +1,243 @@
1
+ import { Model } from '@nan0web/types'
2
+ import { ask, log } from '../../core/Intent.js'
3
+
4
+ /**
5
+ * ShellModel — OLMUI Component Model for CLI Orchestration
6
+ * Canonical CLI entry that describes available operations as a schema.
7
+ */
8
+ export class ShellModel extends Model {
9
+ static $id = '@nan0web/ui/ShellModel'
10
+
11
+ static command = {
12
+ help: 'What do you want to do?',
13
+ type: 'select',
14
+ default: null,
15
+ positional: true,
16
+ // options: [
17
+ // BootEngine,
18
+ // InteractiveCLI,
19
+ // DevMode,
20
+ // BuildProject,
21
+ // TestSSG,
22
+ // SSGGallery,
23
+ // TestWeb,
24
+ // WebGallery,
25
+ // ConfigWizard,
26
+ // ],
27
+ options: [
28
+ { label: '📡 Boot Engine (Run OS)', value: 'run' },
29
+ { label: '🖥️ Interactive CLI', value: 'cli' },
30
+ { label: '🧬 Dev Mode (Hot-Reload)', value: 'dev' },
31
+ { label: '📦 Build Project (Data & UI)', value: 'build' },
32
+ { label: '🧪 Test SSG', value: 'test:ssg' },
33
+ { label: '🔭 SSG Gallery', value: 'ssg:gallery' },
34
+ { label: '🧪 Test Web', value: 'test:web' },
35
+ { label: '🔭 Web Gallery', value: 'web:gallery' },
36
+ { label: '🔧 Config Wizard', value: 'config' },
37
+ ],
38
+ required: true,
39
+ }
40
+
41
+ static data = {
42
+ help: 'Data source (DSN)',
43
+ type: 'string',
44
+ default: 'data/',
45
+ alias: 'dsn',
46
+ }
47
+
48
+ static index = {
49
+ help: 'Directory index file name (e.g. README or index)',
50
+ type: 'string',
51
+ default: 'index',
52
+ }
53
+
54
+ static locale = {
55
+ help: 'Application locale',
56
+ type: 'string',
57
+ default: 'en',
58
+ }
59
+
60
+ static port = {
61
+ help: 'Server port',
62
+ type: 'string',
63
+ default: '3000',
64
+ }
65
+
66
+
67
+ #options = {}
68
+
69
+ /**
70
+ * @param {object} data
71
+ * @param {object} [options] External dependencies (AppRunner, SSRServer, etc.)
72
+ */
73
+ constructor(data = {}, options = {}) {
74
+ super(data)
75
+ this.#options = options
76
+ /** @type {string|null} */ this.command
77
+ /** @type {string} */ this.data
78
+ /** @type {string} */ this.index
79
+ /** @type {string} */ this.locale
80
+ /** @type {string} */ this.port
81
+ }
82
+
83
+ async *run() {
84
+ yield log('info', '📡 NaN0Web Engine OLMUI Shell Ready')
85
+
86
+ if (!this.command || this.command === 'help') {
87
+ const res = yield ask('Shell', ShellModel)
88
+ if (res.cancelled) return
89
+ Object.assign(this, res.value)
90
+ }
91
+
92
+ if (this.command !== 'cli' && this.command !== 'help') {
93
+ yield log('info', `📡 Executing command: ${this.command}...`)
94
+ }
95
+
96
+ switch (this.command) {
97
+ case 'run':
98
+ return yield* this.#runEngine()
99
+ case 'cli':
100
+ return yield* this.#runCli()
101
+ case 'config':
102
+ return yield* this.#runConfig()
103
+ case 'build':
104
+ return yield* this.#runBuild()
105
+ case 'dev':
106
+ return yield* this.#runDev()
107
+ case 'test:ssg':
108
+ case 'ssg:gallery':
109
+ case 'test:web':
110
+ case 'web:gallery':
111
+ return yield* this.#runNpmScript(this.command)
112
+ default:
113
+ yield log('error', `Unknown command: ${this.command}`)
114
+ }
115
+
116
+ this.command = null
117
+ }
118
+
119
+ async *#runCli() {
120
+ const { spawn, locale, dsn } = this.#options
121
+ if (!spawn) {
122
+ yield log('error', 'Spawn utility missing. CLI mode requires Node environment.')
123
+ return
124
+ }
125
+
126
+ const bankFrame = ' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ '
127
+ yield log(
128
+ 'success',
129
+ `\x1b[1m\n${bankFrame}\n📡 Launching Sub-App CLI...\n${bankFrame}\n\x1b[22m`,
130
+ )
131
+
132
+ const absPath = (await import('node:path')).resolve('src/ui/cli/index.js')
133
+ const { existsSync } = await import('node:fs')
134
+ if (!existsSync(absPath)) {
135
+ yield log(
136
+ 'error',
137
+ `No CLI runner found at ${absPath}. Ensure you are in the application root.`,
138
+ )
139
+ return
140
+ }
141
+
142
+ const args = [absPath]
143
+ if (this.locale) args.push('--locale', this.locale)
144
+ if (this.data) args.push('--data', this.data)
145
+
146
+ const extra = (process.argv || []).slice(3)
147
+ args.push(...extra)
148
+
149
+ try {
150
+ const code = await spawn('node', args, { stdio: 'inherit' })
151
+ if (code !== 0) yield log('error', `CLI exited with code ${code}`)
152
+ } catch (/** @type {any} */ e) {
153
+ yield log('error', `Failed to spawn CLI: ${e.message}`)
154
+ }
155
+ }
156
+
157
+ async *#runEngine() {
158
+ const { AppRunner, SSRServer } = this.#options
159
+ if (!AppRunner) return yield log('error', 'AppRunner dependency missing')
160
+
161
+ const runner = new AppRunner({
162
+ dsn: this.data,
163
+ port: this.port,
164
+ locale: this.locale,
165
+ directoryIndex: this.index,
166
+ })
167
+ for await (const msg of runner.run()) {
168
+ yield log('info', msg)
169
+ }
170
+
171
+ const server = new SSRServer(runner)
172
+ const port = runner.config?.port || 3000
173
+ const { protocol } = await server.listen(port)
174
+
175
+ yield log('success', `\n🌐 Server running on ${protocol}://localhost:${port}`)
176
+
177
+ // Keep alive in CLI mode
178
+ if (typeof process !== 'undefined') {
179
+ while (true) {
180
+ await new Promise((r) => setTimeout(r, 60000))
181
+ }
182
+ }
183
+ }
184
+
185
+ async *#runConfig() {
186
+ const { NaN0WebConfig, DBwithFSDriver } = this.#options
187
+ const res = yield ask('config', NaN0WebConfig)
188
+ if (res.cancelled) return
189
+
190
+ const data = res.value
191
+ if (typeof process !== 'undefined' && DBwithFSDriver) {
192
+ const db = new DBwithFSDriver({ cwd: process.cwd() })
193
+ await db.connect()
194
+ await db.saveDocument('nan0web.config.yaml', {
195
+ name: data.name,
196
+ dsn: data.data || data.dsn,
197
+ locale: data.locale,
198
+ port: data.port,
199
+ directoryIndex: data.index,
200
+ })
201
+ yield log('success', '\n✅ Config saved to nan0web.config.yaml')
202
+ }
203
+ }
204
+
205
+ async *#runBuild() {
206
+ const { spawn, AppRunner, SSRServer } = this.#options
207
+ if (!spawn) return yield log('error', 'Spawn missing')
208
+
209
+ const { existsSync } = await import('node:fs')
210
+ const viteConfig = existsSync('vite.docs.js') ? 'vite.docs.js' :
211
+ existsSync('vite.config.js') ? 'vite.config.js' : null
212
+
213
+ if (viteConfig) {
214
+ yield log('info', `🛠 Building UI (Vite using ${viteConfig})...`)
215
+ const exitCode = await spawn('npx', ['vite', 'build', '-c', viteConfig])
216
+ if (exitCode !== 0) yield log('error', '⚠️ Vite build failed.')
217
+ }
218
+
219
+ const runner = new AppRunner({
220
+ dsn: this.data,
221
+ locale: this.locale,
222
+ directoryIndex: this.index,
223
+ })
224
+ for await (const msg of runner.run()) yield log('info', msg)
225
+ const server = new SSRServer(runner)
226
+ const stats = await server.exportStatic('dist')
227
+ yield log('success', `✅ Built ${stats.count}/${stats.total} pages into /dist`)
228
+ }
229
+
230
+ async *#runDev() {
231
+ const { spawn } = this.#options
232
+ if (!spawn) return yield log('error', 'Dev mode requires spawn')
233
+ yield log('info', '🧬 Starting VITE Dev Server...')
234
+ await spawn('npx', ['vite'], { stdio: 'inherit' })
235
+ }
236
+
237
+ async *#runNpmScript(script) {
238
+ const { spawn } = this.#options
239
+ if (!spawn) return
240
+ yield log('info', `🔭 Running npm run ${script}...`)
241
+ await spawn('npm', ['run', script], { stdio: 'inherit' })
242
+ }
243
+ }
@@ -1,10 +1,17 @@
1
1
  import { Model } from '@nan0web/types'
2
+ import { show, result } from '../../core/Intent.js'
2
3
 
3
4
  /**
4
5
  * Model-as-Schema for Table Data component.
5
6
  * Displays tabular string data in rows and columns.
6
7
  */
7
8
  export class TableModel extends Model {
9
+ static $id = '@nan0web/ui/TableModel'
10
+
11
+ static UI = {
12
+ displayingTable: 'Displaying table with {count} rows',
13
+ }
14
+
8
15
  static columns = {
9
16
  help: 'Array of column headers',
10
17
  type: 'string[]',
@@ -34,14 +41,11 @@ export class TableModel extends Model {
34
41
  * @returns {AsyncGenerator<any, any, any>}
35
42
  */
36
43
  async *run() {
37
- yield {
38
- type: 'log',
39
- level: 'info',
40
- message: `Displaying table with ${this.rows?.length || 0} rows`,
44
+ yield show(this._.t(TableModel.UI.displayingTable, { count: this.rows?.length || 0 }), 'info', {
41
45
  component: 'Table',
42
46
  model: this,
43
- }
47
+ })
44
48
 
45
- return { type: 'result', data: { rowsCount: this.rows?.length || 0 } }
49
+ return result({ rowsCount: this.rows?.length || 0 })
46
50
  }
47
51
  }
@@ -1,9 +1,16 @@
1
1
  import { Model } from '@nan0web/types'
2
+ import { show, result } from '../../core/Intent.js'
2
3
 
3
4
  /**
4
5
  * Model-as-Schema for Toast notification.
5
6
  */
6
7
  export class ToastModel extends Model {
8
+ static $id = '@nan0web/ui/ToastModel'
9
+
10
+ static UI = {
11
+ toastLog: '{message}',
12
+ }
13
+
7
14
  static variant = {
8
15
  help: 'Notification color scheme',
9
16
  default: 'info',
@@ -37,14 +44,11 @@ export class ToastModel extends Model {
37
44
  * @returns {AsyncGenerator<any, any, any>}
38
45
  */
39
46
  async *run() {
40
- yield {
41
- type: 'log',
42
- level: this.variant === 'error' ? 'error' : 'info',
43
- message: this.message,
47
+ yield show(this._.t(ToastModel.UI.toastLog, { message: this.message }), this.variant === 'error' ? 'error' : 'info', {
44
48
  component: 'Toast',
45
49
  model: this,
46
- }
50
+ })
47
51
 
48
- return { type: 'result', data: { shown: true } }
52
+ return result({ dismissed: true })
49
53
  }
50
54
  }
@@ -20,6 +20,7 @@ export { GalleryModel } from './GalleryModel.js'
20
20
  export { PriceModel } from './PriceModel.js'
21
21
  export { PricingModel } from './PricingModel.js'
22
22
  export { PricingSectionModel } from './PricingSectionModel.js'
23
+ export { FeatureGridModel, FeatureItemModel } from './FeatureGridModel.js'
23
24
 
24
25
  // Hero
25
26
 
@@ -39,7 +40,8 @@ export { HeaderConfigModel } from './HeaderConfigModel.js'
39
40
  export { FooterVisibilityModel } from './FooterVisibilityModel.js'
40
41
  export { FooterConfigModel } from './FooterConfigModel.js'
41
42
 
42
- // Business Critical
43
43
  export { EmptyStateModel } from './EmptyStateModel.js'
44
44
  export { BannerModel } from './BannerModel.js'
45
45
  export { ProfileDropdownModel } from './ProfileDropdownModel.js'
46
+ export { MarkdownModel } from './MarkdownModel.js'
47
+ export { ShellModel } from './ShellModel.js'
@@ -1,13 +1,19 @@
1
+ // Model runner (Model as App)
2
+ /** @typedef {import('./ModelAsApp.js').ModelAsAppOptions} ModelAsAppOptions */
3
+ export { ModelAsApp } from './ModelAsApp.js'
4
+
1
5
  // Domain Models — OLMUI Model-as-Schema
2
6
  export { SandboxModel } from './SandboxModel.js'
7
+ export { Content } from './Content.js'
8
+ export { Document } from './Document.js'
3
9
  export { ShowcaseAppModel } from './ShowcaseAppModel.js'
4
10
  export { default as Navigation } from './Navigation.js'
5
- export { Language } from '@nan0web/i18n/src/domain/Language.js'
6
11
 
7
12
  // Layout Models (Phase 1)
8
- export { default as HeaderModel } from './HeaderModel.js'
9
- export { default as FooterModel } from './FooterModel.js'
10
- export { default as HeroModel } from './HeroModel.js'
13
+ export { LayoutModel } from './LayoutModel.js'
14
+ export { HeaderModel } from './HeaderModel.js'
15
+ export { FooterModel } from './FooterModel.js'
16
+ export { HeroModel } from './HeroModel.js'
11
17
 
12
18
  // Component Models
13
19
  export {
@@ -24,6 +30,8 @@ export {
24
30
  AccordionModel,
25
31
  GalleryModel,
26
32
  PriceModel,
33
+ FeatureGridModel,
34
+ FeatureItemModel,
27
35
  PricingModel,
28
36
  CommentModel,
29
37
  TestimonialModel,
@@ -39,3 +47,5 @@ export {
39
47
  BannerModel,
40
48
  ProfileDropdownModel,
41
49
  } from './components/index.js'
50
+
51
+ export { ShellModel } from './components/ShellModel.js'
package/src/index.js CHANGED
@@ -12,7 +12,6 @@ import App from './App/index.js'
12
12
  export { Frame, FrameProps, Locale, StdIn, StdOut, View, RenderOptions, Model, Component, App }
13
13
  export { format } from './format.js'
14
14
  export { default as Navigation } from './domain/Navigation.js'
15
- export { Language } from '@nan0web/i18n/src/domain/Language.js'
16
15
 
17
16
  // export default App
18
17
  export { default as FormMessage } from './core/Form/Message.js'
@@ -27,6 +26,26 @@ export { default as Error, CancelError } from './core/Error/index.js'
27
26
  export { default as UiAdapter } from './core/UiAdapter.js'
28
27
 
29
28
  // OLMUI Generator Engine
30
- export { validateIntent, ask, progress, log, result, INTENT_TYPES, isModelSchema } from './core/Intent.js'
29
+ /** @typedef {import('./core/Intent.js').LogLevel} LogLevel */
30
+ /** @typedef {import('./core/Intent.js').ShowLevel} ShowLevel */
31
+ /** @typedef {import('./core/Intent.js').FieldSchema} FieldSchema */
32
+ /** @typedef {import('./core/Intent.js').Intent} Intent */
33
+ /** @typedef {import('./core/Intent.js').IntentResponse} IntentResponse */
34
+ /** @typedef {import('./core/Intent.js').AskIntent} AskIntent */
35
+ /** @typedef {import('./core/Intent.js').ProgressIntent} ProgressIntent */
36
+ /** @typedef {import('./core/Intent.js').LogIntent} LogIntent */
37
+ /** @typedef {import('./core/Intent.js').ShowIntent} ShowIntent */
38
+ /** @typedef {import('./core/Intent.js').RenderIntent} RenderIntent */
39
+ /** @typedef {import('./core/Intent.js').ResultIntent} ResultIntent */
40
+ /** @typedef {import('./core/Intent.js').IntentType} IntentType */
41
+ /** @typedef {import('./core/Intent.js').AskResponse} AskResponse */
42
+ /** @typedef {import('./core/Intent.js').AbortResponse} AbortResponse */
43
+ /** @typedef {import('./core/Intent.js').ShowData} ShowData */
44
+ export * from './core/Intent.js'
45
+
31
46
  export { IntentErrorModel } from './core/IntentErrorModel.js'
32
47
  export { runGenerator } from './core/GeneratorRunner.js'
48
+ export { buildNan0SpecFromTrace } from './testing/CrashReporter.js'
49
+
50
+ /** @typedef {import('./domain/index.js').ModelAsAppOptions} ModelAsAppOptions */
51
+ export * from './domain/index.js'
package/src/inspect.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './testing/SnapshotRunner.js'
2
+ export * from './testing/verifySnapshot.js'
@@ -0,0 +1,59 @@
1
+ import InputAdapter from '../core/InputAdapter.js'
2
+
3
+ /**
4
+ * Deterministic Scenario Adapter for OLMUI Testing.
5
+ *
6
+ * Drives the Model generator through a predefined script of responses,
7
+ * enabling millisecond-fast verification of complex business logic.
8
+ */
9
+ export default class ScenarioAdapter extends InputAdapter {
10
+ /**
11
+ * @param {Array<{field: string, value: any, cancelled?: boolean}>} [scenario=[]]
12
+ */
13
+ constructor(scenario = []) {
14
+ super()
15
+ this.scenario = scenario
16
+ this.intents = [] // Recorded intents for verification
17
+ this.console = {
18
+ info: () => {},
19
+ warn: () => {},
20
+ error: () => {},
21
+ debug: () => {},
22
+ log: () => {},
23
+ }
24
+ }
25
+
26
+ /**
27
+ * @param {import('../core/Intent.js').AskIntent} intent
28
+ * @returns {Promise<import('../core/Intent.js').AskResponse>}
29
+ */
30
+ async askIntent(intent) {
31
+ this.intents.push(intent)
32
+ const match = this.scenario.find((s) => s.field === intent.field)
33
+ if (match) {
34
+ return { value: match.value, cancelled: !!match.cancelled }
35
+ }
36
+ // If no specific match, try to use the first available answer or default
37
+ return { value: null }
38
+ }
39
+
40
+ /** @param {import('../core/Intent.js').ProgressIntent} intent */
41
+ async progressIntent(intent) {
42
+ this.intents.push(intent)
43
+ }
44
+
45
+ /** @param {import('../core/Intent.js').ShowIntent} intent */
46
+ async showIntent(intent) {
47
+ this.intents.push(intent)
48
+ }
49
+
50
+ /** @param {import('../core/Intent.js').RenderIntent} intent */
51
+ async renderIntent(intent) {
52
+ this.intents.push(intent)
53
+ }
54
+
55
+ /** @param {import('../core/Intent.js').ResultIntent} intent */
56
+ async resultIntent(intent) {
57
+ this.intents.push(intent)
58
+ }
59
+ }
@@ -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
+ }
@@ -0,0 +1,29 @@
1
+ import path from 'path'
2
+ import { fileURLToPath } from 'url'
3
+ import { SnapshotRunner } from './SnapshotRunner.js'
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
6
+ const rootDir = path.resolve(__dirname, '../../')
7
+ const dataDir = path.resolve(rootDir, 'docs/data')
8
+ const snapshotsDir = path.resolve(rootDir, 'snapshots/core')
9
+
10
+ const groups = {
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'],
16
+ }
17
+
18
+ function getCategory(comp) {
19
+ for (const [cat, comps] of Object.entries(groups)) {
20
+ if (comps.includes(comp)) return cat
21
+ }
22
+ return 'Other'
23
+ }
24
+
25
+ SnapshotRunner.generateAndAudit({
26
+ dataDir,
27
+ snapshotsDir,
28
+ getCategory,
29
+ }).catch(console.error)