@nan0web/ui 1.12.0 → 1.12.2

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 CHANGED
@@ -250,6 +250,16 @@ const res = await ScenarioTest.run(ValidatedApp, [
250
250
  { field: 'code', value: '' } // Simulating empty response
251
251
  ])
252
252
  ```
253
+ ### Story Testing (.nan0 spec files)
254
+
255
+ The `SpecRunner.executeFile` helper allows running `.nan0` spec stories automatically without boilerplate DBFS setup.
256
+ All manual assertions are omitted because `SpecAdapter` handles strict expectation matching internally.
257
+
258
+ How to execute .nan0 spec files automatically?
259
+ ```js
260
+ import { SpecRunner } from '@nan0web/ui/testing'
261
+ const { SpecRunner } = await import('./testing/index.js')
262
+ ```
253
263
  All components, adapters, and models are designed to be testable
254
264
  with minimal setup.
255
265
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nan0web/ui",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
4
4
  "description": "NaN•Web UI. One application logic (algorithm) and many UI.",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",
@@ -74,23 +74,23 @@
74
74
  "lit": "^3.3.2",
75
75
  "vite": "^6.4.1",
76
76
  "vitest": "^3.2.4",
77
- "@nan0web/db-fs": "1.2.1",
77
+ "@nan0web/db-fs": "1.2.2",
78
78
  "@nan0web/event": "1.0.1",
79
- "@nan0web/icons": "1.1.0",
80
79
  "@nan0web/i18n": "1.5.0",
81
- "@nan0web/ui-cli": "2.13.0",
82
80
  "@nan0web/inspect": "1.0.0",
83
81
  "@nan0web/test": "1.1.4",
84
- "@nan0web/nan0web.app": "0.1.0",
82
+ "@nan0web/ui-cli": "2.13.1",
85
83
  "@nan0web/release": "1.0.3",
86
- "@nan0web/ui-lit": "1.1.0"
84
+ "@nan0web/icons": "1.1.0",
85
+ "@nan0web/ui-lit": "1.1.0",
86
+ "@nan0web/nan0web.app": "0.1.0"
87
87
  },
88
88
  "dependencies": {
89
89
  "string-width": "^7.2.0",
90
90
  "@nan0web/co": "2.0.1",
91
91
  "@nan0web/core": "1.1.3",
92
- "@nan0web/log": "1.1.1",
93
- "@nan0web/types": "1.7.2"
92
+ "@nan0web/types": "1.7.3",
93
+ "@nan0web/log": "1.1.1"
94
94
  },
95
95
  "scripts": {
96
96
  "prebuild": "rm -rf types/",
@@ -15,7 +15,7 @@ export default class CoreApp {
15
15
  /** @type {Map<string, CommandFn>} Registered command handlers */
16
16
  commands
17
17
 
18
- /** @type {object} App state */
18
+ /** @type {Record<string, any>} App state */
19
19
  state
20
20
 
21
21
  /** @type {Message} Starting command parsed from argv */
package/src/View/View.js CHANGED
@@ -28,7 +28,7 @@ export default class View {
28
28
  frame
29
29
  /** @type {Locale} */
30
30
  locale
31
- /** @type {Map} */
31
+ /** @type {Map<string, string>} */
32
32
  vocab
33
33
  /** @type {number[]} */
34
34
  windowSize
@@ -43,7 +43,7 @@ export default class View {
43
43
  * @param {number} [input.startedAt]
44
44
  * @param {Frame} [input.frame]
45
45
  * @param {Locale} [input.locale]
46
- * @param {Map} [input.vocab]
46
+ * @param {Map<string, string>} [input.vocab]
47
47
  * @param {number[]} [input.windowSize]
48
48
  * @param {Map<string, ComponentFn>} [input.components]
49
49
  * @param {string} [input.renderMethod]
@@ -66,7 +66,7 @@ export default class View {
66
66
  this.frame = frame
67
67
  this.locale = locale
68
68
  this.vocab = vocab
69
- this.windowSize = null === windowSize ? this.stdout.getWindowSize() : windowSize
69
+ this.windowSize = /** @type {number[]} */ (null === windowSize ? this.stdout.getWindowSize() : windowSize)
70
70
  this.components = components
71
71
  this.renderMethod = renderMethod
72
72
  if (!empty(frame)) {
@@ -85,6 +85,10 @@ export default class View {
85
85
  getWindowSize() {
86
86
  return equal(this.windowSize, [0, 0]) ? this.stdout.getWindowSize() : this.windowSize
87
87
  }
88
+ /**
89
+ * @param {number} width
90
+ * @param {number} height
91
+ */
88
92
  setWindowSize(width, height) {
89
93
  this.windowSize = [width, height]
90
94
  }
@@ -181,6 +185,7 @@ export default class View {
181
185
  }
182
186
  }
183
187
 
188
+ /** @param {any} value */
184
189
  t(value) {
185
190
  if (typeOf(Array)(value)) {
186
191
  value = value.map((row) => {
@@ -196,16 +201,22 @@ export default class View {
196
201
  return this.vocab.has(value) ? this.vocab.get(value) : value
197
202
  }
198
203
 
204
+ /** @param {any[]} args */
199
205
  debug(...args) {
206
+ // @ts-ignore
200
207
  return this.render(1)([StdOut.STYLES.dim, 'Debug: ', args.join(' '), Frame.EOL, StdOut.RESET])
201
208
  }
202
209
 
210
+ /** @param {any[]} args */
203
211
  info(...args) {
212
+ // @ts-ignore
204
213
  return this.render(1)([StdOut.COLORS.green, 'Info : ', args.join(' '), Frame.EOL, StdOut.RESET])
205
214
  }
206
215
 
216
+ /** @param {any[]} args */
207
217
  warn(...args) {
208
218
  return this.render(1)([
219
+ // @ts-ignore
209
220
  StdOut.COLORS.yellow,
210
221
  'Warn : ',
211
222
  args.join(' '),
@@ -214,8 +225,10 @@ export default class View {
214
225
  ])
215
226
  }
216
227
 
228
+ /** @param {any[]} args */
217
229
  error(...args) {
218
230
  return this.render(1)([
231
+ // @ts-ignore
219
232
  StdOut.COLORS.red,
220
233
  StdOut.STYLES.bold,
221
234
  'Error: ',
@@ -276,6 +276,14 @@ export async function runGenerator(generator, handlers, options = {}) {
276
276
  break
277
277
  }
278
278
 
279
+ case 'result': {
280
+ if (handlers.result) {
281
+ await handlers.result(intent)
282
+ }
283
+ nextVal = undefined
284
+ break
285
+ }
286
+
279
287
  default:
280
288
  throw IntentErrorModel.error('unhandled_intent', { type: /** @type {any} */ (intent).type })
281
289
  }
@@ -72,6 +72,7 @@ import { IntentErrorModel } from './IntentErrorModel.js'
72
72
  * @typedef {Object} ResultIntent
73
73
  * @property {'result'} type
74
74
  * @property {*} data - The raw result data (JSON-serializable).
75
+ * @property {boolean} [raw] - If true, Adapter MUST output data raw (no UI decorations).
75
76
  */
76
77
 
77
78
  /**
@@ -312,10 +313,11 @@ export function render(component, props = {}) {
312
313
  /**
313
314
  * Create a result intent.
314
315
  * @param {*} data - The raw result data.
316
+ * @param {boolean} [raw=false] - If true, result is printed raw.
315
317
  * @returns {ResultIntent}
316
318
  */
317
- export function result(data) {
318
- return { type: 'result', data }
319
+ export function result(data, raw = false) {
320
+ return { type: 'result', data, raw }
319
321
  }
320
322
 
321
323
  /**
@@ -16,7 +16,7 @@ export class ModelAsApp extends Model {
16
16
  }
17
17
  /**
18
18
  * @param {Partial<ModelAsApp> | Record<string, any>} [data={}]
19
- * @param {ModelAsAppOptions} [options={}]
19
+ * @param {Partial<ModelAsAppOptions>} [options={}]
20
20
  */
21
21
  constructor(data = {}, options = {}) {
22
22
  super(data, options)
@@ -0,0 +1,61 @@
1
+ import { ModelAsApp } from '../ModelAsApp.js'
2
+ import { show, result, render } from '../../core/Intent.js'
3
+
4
+ export default class ConfigApp extends ModelAsApp {
5
+ static name = 'config'
6
+ static alias = 'cfg'
7
+
8
+ static resource = {
9
+ type: 'string',
10
+ help: 'Resource to configure (e.g. agents)',
11
+ positional: true,
12
+ default: 'agents',
13
+ }
14
+
15
+ static action = {
16
+ type: 'string',
17
+ help: 'Action to perform (e.g. list)',
18
+ positional: true,
19
+ default: 'list',
20
+ }
21
+
22
+ constructor(data = {}, options = {}) {
23
+ super(data, options)
24
+ this.resource = data.resource || 'agents'
25
+ this.action = data.action || 'list'
26
+ }
27
+
28
+ async *run() {
29
+ if (this.resource === 'agents') {
30
+ if (this.action === 'list') {
31
+ const DBFS = (await import('@nan0web/db-fs')).default
32
+ const db = new DBFS({ root: process.cwd() })
33
+ const config = await db.loadDocument('nan0web.nan0', {}).catch(() => ({}))
34
+
35
+ if (!config || !config.agents || !Array.isArray(config.agents)) {
36
+ yield show('No agents configured in nan0web.nan0')
37
+ return result({ success: true })
38
+ }
39
+
40
+ const tableData = config.agents.map((a) => ({
41
+ ID: a.id,
42
+ Description: a.description,
43
+ Workflows: a.workflows ? a.workflows.length : 0,
44
+ Inspectors: a.inspectors ? a.inspectors.length : 0,
45
+ }))
46
+
47
+ yield render('Table', {
48
+ data: tableData,
49
+ columns: ['ID', 'Description', 'Workflows', 'Inspectors'],
50
+ interactive: false
51
+ })
52
+ return result({ success: true })
53
+ }
54
+ yield show(`Unknown action for agents: ${this.action}`, 'error')
55
+ return result({ success: false })
56
+ }
57
+
58
+ yield show(`Unknown config resource: ${this.resource}`, 'error')
59
+ return result({ success: false })
60
+ }
61
+ }
@@ -2,13 +2,14 @@ import { ModelAsApp } from '../ModelAsApp.js'
2
2
  import { resolvePositionalArgs } from '@nan0web/ui-cli'
3
3
  import SnapshotAuditor from './SnapshotAuditor.js'
4
4
  import GalleryCommand from './GalleryCommand.js'
5
+ import ConfigApp from './ConfigApp.js'
5
6
  import { show, result } from '../../core/Intent.js'
6
7
 
7
8
  export class UIApp extends ModelAsApp {
8
9
  static command = {
9
10
  type: 'string',
10
11
  help: 'Command to run (e.g. gallery)',
11
- options: [GalleryCommand, SnapshotAuditor],
12
+ options: [GalleryCommand, SnapshotAuditor, ConfigApp],
12
13
  default: GalleryCommand.alias || GalleryCommand.name,
13
14
  positional: true,
14
15
  }
@@ -35,6 +35,43 @@ export class SpecRunner extends ModelAsApp {
35
35
  this.registry
36
36
  }
37
37
 
38
+ /**
39
+ * Convenience method to load a .nan0 file and run a specific scenario.
40
+ *
41
+ * 💡 Note on Expectations:
42
+ * You do NOT need to write manual assertions when using this method.
43
+ * The `for await (const _ of runner.run()) {}` loop drives the generator,
44
+ * but ALL assertions are handled automatically inside `SpecAdapter.js`.
45
+ *
46
+ * Whenever the App yields an intent (`ask`, `show`, `result`), `SpecAdapter`
47
+ * intercepts it and compares it strictly against the next step in the `.nan0` file.
48
+ * - If it matches, the test continues (and `$value` is injected back into the App).
49
+ * - If it mismatches, it throws an `assert.fail()` which fails the Node.js test immediately.
50
+ * - If the App finishes early, it throws an `unhandledSteps` error.
51
+ *
52
+ * @param {string} fileDir The directory containing the file (e.g., import.meta.dirname)
53
+ * @param {string} fileName The name of the .nan0 file
54
+ * @param {string} scenarioName The name of the scenario to run
55
+ * @param {Record<string, any>} registry The Model Class registry
56
+ * @param {Partial<import('../index.js').ModelAsAppOptions>} [options={}] Additional runner context options
57
+ * @throws {Error} If the scenario is missing or if expectations fail during execution
58
+ */
59
+ static async executeFile(fileDir, fileName, scenarioName, registry, options = {}) {
60
+ const DB = (await import('@nan0web/db-fs')).DBFS
61
+ const db = new DB({ root: fileDir })
62
+ const doc = await db.loadDocument(fileName)
63
+ const scenarios = Array.isArray(doc) ? doc : [doc]
64
+ const scenario = scenarios.find((s) => s.name === scenarioName) || scenarios[0]
65
+
66
+ if (!scenario) throw new Error(`Scenario ${scenarioName} not found in ${fileName}`)
67
+ if (!scenario.story) throw new Error(`Scenario ${scenarioName} has no story array`)
68
+
69
+ const runner = new this({ stream: scenario.story, registry }, options)
70
+ for await (const _ of runner.run()) {
71
+ // Iterate completely
72
+ }
73
+ }
74
+
38
75
  /**
39
76
  * @throws {Error}
40
77
  * @returns {AsyncGenerator<import('../core/Intent.js').Intent, import('../core/Intent.js').ResultIntent, any>}
@@ -20,8 +20,8 @@ export default class CoreApp {
20
20
  name: string;
21
21
  /** @type {Map<string, CommandFn>} Registered command handlers */
22
22
  commands: Map<string, CommandFn>;
23
- /** @type {object} App state */
24
- state: object;
23
+ /** @type {Record<string, any>} App state */
24
+ state: Record<string, any>;
25
25
  /** @type {Message} Starting command parsed from argv */
26
26
  startCommand: Message;
27
27
  /**
@@ -22,7 +22,7 @@ export default class View {
22
22
  * @param {number} [input.startedAt]
23
23
  * @param {Frame} [input.frame]
24
24
  * @param {Locale} [input.locale]
25
- * @param {Map} [input.vocab]
25
+ * @param {Map<string, string>} [input.vocab]
26
26
  * @param {number[]} [input.windowSize]
27
27
  * @param {Map<string, ComponentFn>} [input.components]
28
28
  * @param {string} [input.renderMethod]
@@ -33,7 +33,7 @@ export default class View {
33
33
  startedAt?: number | undefined;
34
34
  frame?: Frame | undefined;
35
35
  locale?: Locale | undefined;
36
- vocab?: Map<any, any> | undefined;
36
+ vocab?: Map<string, string> | undefined;
37
37
  windowSize?: number[] | undefined;
38
38
  components?: Map<string, ComponentFn> | undefined;
39
39
  renderMethod?: string | undefined;
@@ -48,8 +48,8 @@ export default class View {
48
48
  frame: Frame;
49
49
  /** @type {Locale} */
50
50
  locale: Locale;
51
- /** @type {Map} */
52
- vocab: Map<any, any>;
51
+ /** @type {Map<string, string>} */
52
+ vocab: Map<string, string>;
53
53
  /** @type {number[]} */
54
54
  windowSize: number[];
55
55
  /** @type {Map<string, ComponentFn>} */
@@ -60,7 +60,11 @@ export default class View {
60
60
  get RenderMethod(): typeof FrameRenderMethod;
61
61
  get RenderOptions(): typeof RenderOptions;
62
62
  getWindowSize(): number[];
63
- setWindowSize(width: any, height: any): void;
63
+ /**
64
+ * @param {number} width
65
+ * @param {number} height
66
+ */
67
+ setWindowSize(width: number, height: number): void;
64
68
  startTimer(): void;
65
69
  spent(checkpoint?: number): number;
66
70
  /**
@@ -71,10 +75,15 @@ export default class View {
71
75
  render(shouldRender?: boolean | number | Function | ComponentFn, options?: RenderOptions): (value: Frame | string | string[], ...args: any) => Frame;
72
76
  clear(shouldRender?: number): Frame;
73
77
  progress(shouldRender?: boolean): (value: any) => Frame;
78
+ /** @param {any} value */
74
79
  t(value: any): any;
80
+ /** @param {any[]} args */
75
81
  debug(...args: any[]): Frame;
82
+ /** @param {any[]} args */
76
83
  info(...args: any[]): Frame;
84
+ /** @param {any[]} args */
77
85
  warn(...args: any[]): Frame;
86
+ /** @param {any[]} args */
78
87
  error(...args: any[]): Frame;
79
88
  /**
80
89
  * @param {string} name
@@ -59,9 +59,10 @@ export function render(component: string, props?: object): RenderIntent;
59
59
  /**
60
60
  * Create a result intent.
61
61
  * @param {*} data - The raw result data.
62
+ * @param {boolean} [raw=false] - If true, result is printed raw.
62
63
  * @returns {ResultIntent}
63
64
  */
64
- export function result(data: any): ResultIntent;
65
+ export function result(data: any, raw?: boolean): ResultIntent;
65
66
  /**
66
67
  * @typedef {Object} ShowData
67
68
  * @property {any} [component]
@@ -135,6 +136,7 @@ export function agent(task: string, context?: AgentContext): AgentIntent;
135
136
  * @typedef {Object} ResultIntent
136
137
  * @property {'result'} type
137
138
  * @property {*} data - The raw result data (JSON-serializable).
139
+ * @property {boolean} [raw] - If true, Adapter MUST output data raw (no UI decorations).
138
140
  */
139
141
  /**
140
142
  * Model requests rendering of a pure UI component (Header, Footer, Static Map).
@@ -364,6 +366,10 @@ export type ResultIntent = {
364
366
  * - The raw result data (JSON-serializable).
365
367
  */
366
368
  data: any;
369
+ /**
370
+ * - If true, Adapter MUST output data raw (no UI decorations).
371
+ */
372
+ raw?: boolean | undefined;
367
373
  };
368
374
  /**
369
375
  * Model requests rendering of a pure UI component (Header, Footer, Static Map).
@@ -5,9 +5,9 @@
5
5
  export class ModelAsApp extends Model {
6
6
  /**
7
7
  * @param {Partial<ModelAsApp> | Record<string, any>} [data={}]
8
- * @param {ModelAsAppOptions} [options={}]
8
+ * @param {Partial<ModelAsAppOptions>} [options={}]
9
9
  */
10
- constructor(data?: Partial<ModelAsApp> | Record<string, any>, options?: ModelAsAppOptions);
10
+ constructor(data?: Partial<ModelAsApp> | Record<string, any>, options?: Partial<ModelAsAppOptions>);
11
11
  /** @returns {ModelAsAppOptions} */
12
12
  get _(): ModelAsAppOptions;
13
13
  /**
@@ -0,0 +1,21 @@
1
+ export default class ConfigApp extends ModelAsApp {
2
+ static name: string;
3
+ static alias: string;
4
+ static resource: {
5
+ type: string;
6
+ help: string;
7
+ positional: boolean;
8
+ default: string;
9
+ };
10
+ static action: {
11
+ type: string;
12
+ help: string;
13
+ positional: boolean;
14
+ default: string;
15
+ };
16
+ constructor(data?: {}, options?: {});
17
+ resource: any;
18
+ action: any;
19
+ run(): AsyncGenerator<import("../../core/Intent.js").ShowIntent | import("../../core/Intent.js").RenderIntent, import("../../core/Intent.js").ResultIntent, unknown>;
20
+ }
21
+ import { ModelAsApp } from '../ModelAsApp.js';
@@ -2,7 +2,7 @@ export class UIApp extends ModelAsApp {
2
2
  static command: {
3
3
  type: string;
4
4
  help: string;
5
- options: (typeof SnapshotAuditor | typeof GalleryCommand)[];
5
+ options: (typeof SnapshotAuditor | typeof GalleryCommand | typeof ConfigApp)[];
6
6
  default: string;
7
7
  positional: boolean;
8
8
  };
@@ -22,7 +22,7 @@ export class UIApp extends ModelAsApp {
22
22
  /** @type {string[]} */ _positionals: string[];
23
23
  /** @type {string} Type of command to run */ command: string;
24
24
  /** @type {boolean} Show help message */ help: boolean;
25
- run(): AsyncGenerator<import("../../core/Intent.js").ShowIntent | (import("../../core/Intent.js").AskIntent & {
25
+ run(): AsyncGenerator<import("../../core/Intent.js").ShowIntent | import("../../core/Intent.js").RenderIntent | (import("../../core/Intent.js").AskIntent & {
26
26
  $value?: any;
27
27
  $success?: boolean;
28
28
  $files?: Record<string, string>;
@@ -37,11 +37,6 @@ export class UIApp extends ModelAsApp {
37
37
  $success?: boolean;
38
38
  $files?: Record<string, string>;
39
39
  $message?: string;
40
- }) | (import("../../core/Intent.js").RenderIntent & {
41
- $value?: any;
42
- $success?: boolean;
43
- $files?: Record<string, string>;
44
- $message?: string;
45
40
  }) | (import("../../core/Intent.js").AgentIntent & {
46
41
  $value?: any;
47
42
  $success?: boolean;
@@ -58,3 +53,4 @@ export default UIApp;
58
53
  import { ModelAsApp } from '../ModelAsApp.js';
59
54
  import SnapshotAuditor from './SnapshotAuditor.js';
60
55
  import GalleryCommand from './GalleryCommand.js';
56
+ import ConfigApp from './ConfigApp.js';
@@ -16,6 +16,28 @@ export class SpecRunner extends ModelAsApp {
16
16
  running: string;
17
17
  unhandledSteps: string;
18
18
  };
19
+ /**
20
+ * Convenience method to load a .nan0 file and run a specific scenario.
21
+ *
22
+ * 💡 Note on Expectations:
23
+ * You do NOT need to write manual assertions when using this method.
24
+ * The `for await (const _ of runner.run()) {}` loop drives the generator,
25
+ * but ALL assertions are handled automatically inside `SpecAdapter.js`.
26
+ *
27
+ * Whenever the App yields an intent (`ask`, `show`, `result`), `SpecAdapter`
28
+ * intercepts it and compares it strictly against the next step in the `.nan0` file.
29
+ * - If it matches, the test continues (and `$value` is injected back into the App).
30
+ * - If it mismatches, it throws an `assert.fail()` which fails the Node.js test immediately.
31
+ * - If the App finishes early, it throws an `unhandledSteps` error.
32
+ *
33
+ * @param {string} fileDir The directory containing the file (e.g., import.meta.dirname)
34
+ * @param {string} fileName The name of the .nan0 file
35
+ * @param {string} scenarioName The name of the scenario to run
36
+ * @param {Record<string, any>} registry The Model Class registry
37
+ * @param {Partial<import('../index.js').ModelAsAppOptions>} [options={}] Additional runner context options
38
+ * @throws {Error} If the scenario is missing or if expectations fail during execution
39
+ */
40
+ static executeFile(fileDir: string, fileName: string, scenarioName: string, registry: Record<string, any>, options?: Partial<import("../index.js").ModelAsAppOptions>): Promise<void>;
19
41
  /**
20
42
  * Run a Nan0Spec sequence programmatically (for unit tests).
21
43
  *