@xemahq/ui-kernel 0.7.0 → 0.8.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 (76) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/biome-host/frontend-biome.d.ts +6 -3
  6. package/dist/lib/biome-host/frontend-biome.d.ts.map +1 -1
  7. package/dist/lib/biome-host/frontend-biome.js +9 -0
  8. package/dist/lib/biome-host/frontend-biome.js.map +1 -1
  9. package/dist/offline/id.d.ts +2 -0
  10. package/dist/offline/id.d.ts.map +1 -0
  11. package/dist/offline/id.js +15 -0
  12. package/dist/offline/id.js.map +1 -0
  13. package/dist/offline/index.d.ts +9 -0
  14. package/dist/offline/index.d.ts.map +1 -0
  15. package/dist/offline/index.js +26 -0
  16. package/dist/offline/index.js.map +1 -0
  17. package/dist/offline/mutation.d.ts +15 -0
  18. package/dist/offline/mutation.d.ts.map +1 -0
  19. package/dist/offline/mutation.js +37 -0
  20. package/dist/offline/mutation.js.map +1 -0
  21. package/dist/offline/offline-status-badge.d.ts +7 -0
  22. package/dist/offline/offline-status-badge.d.ts.map +1 -0
  23. package/dist/offline/offline-status-badge.js +21 -0
  24. package/dist/offline/offline-status-badge.js.map +1 -0
  25. package/dist/offline/online-controller.d.ts +18 -0
  26. package/dist/offline/online-controller.d.ts.map +1 -0
  27. package/dist/offline/online-controller.js +79 -0
  28. package/dist/offline/online-controller.js.map +1 -0
  29. package/dist/offline/outbox.d.ts +57 -0
  30. package/dist/offline/outbox.d.ts.map +1 -0
  31. package/dist/offline/outbox.js +257 -0
  32. package/dist/offline/outbox.js.map +1 -0
  33. package/dist/offline/react.d.ts +13 -0
  34. package/dist/offline/react.d.ts.map +1 -0
  35. package/dist/offline/react.js +42 -0
  36. package/dist/offline/react.js.map +1 -0
  37. package/dist/offline/storage.d.ts +22 -0
  38. package/dist/offline/storage.d.ts.map +1 -0
  39. package/dist/offline/storage.js +90 -0
  40. package/dist/offline/storage.js.map +1 -0
  41. package/dist/offline/types.d.ts +38 -0
  42. package/dist/offline/types.d.ts.map +1 -0
  43. package/dist/offline/types.js +11 -0
  44. package/dist/offline/types.js.map +1 -0
  45. package/dist/ui/branding/app-branding-provider.d.ts +1 -1
  46. package/dist/ui/branding/app-branding-provider.d.ts.map +1 -1
  47. package/dist/ui/capture/field-spec-to-zod.d.ts.map +1 -1
  48. package/dist/ui/capture/field-spec-to-zod.js +9 -1
  49. package/dist/ui/capture/field-spec-to-zod.js.map +1 -1
  50. package/dist/ui/capture/field-spec.d.ts +6 -1
  51. package/dist/ui/capture/field-spec.d.ts.map +1 -1
  52. package/dist/ui/capture/field-spec.js +3 -0
  53. package/dist/ui/capture/field-spec.js.map +1 -1
  54. package/dist/ui/capture/schema-field-input.d.ts.map +1 -1
  55. package/dist/ui/capture/schema-field-input.js +63 -0
  56. package/dist/ui/capture/schema-field-input.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/index.ts +4 -0
  59. package/src/lib/biome-host/define-web-biome.spec.ts +221 -0
  60. package/src/lib/biome-host/frontend-biome.ts +32 -5
  61. package/src/offline/id.ts +16 -0
  62. package/src/offline/index.ts +52 -0
  63. package/src/offline/mutation.ts +75 -0
  64. package/src/offline/offline-status-badge.tsx +51 -0
  65. package/src/offline/online-controller.ts +105 -0
  66. package/src/offline/outbox.test.ts +156 -0
  67. package/src/offline/outbox.ts +344 -0
  68. package/src/offline/react.tsx +71 -0
  69. package/src/offline/storage.ts +107 -0
  70. package/src/offline/types.ts +77 -0
  71. package/src/ui/branding/app-branding-provider.tsx +1 -1
  72. package/src/ui/capture/field-spec-to-zod.test.ts +22 -0
  73. package/src/ui/capture/field-spec-to-zod.ts +12 -1
  74. package/src/ui/capture/field-spec.ts +24 -3
  75. package/src/ui/capture/schema-field-input.tsx +129 -1
  76. package/src/lib/biome-host/define-web-biome.test.js +0 -93
@@ -0,0 +1,107 @@
1
+ import type { OutboxEntry, OutboxStorage } from './types';
2
+
3
+ /** In-memory outbox storage — the SSR / test fallback (no persistence). */
4
+ export class InMemoryOutboxStorage implements OutboxStorage {
5
+ private readonly map = new Map<string, OutboxEntry>();
6
+
7
+ async getAll(): Promise<OutboxEntry[]> {
8
+ return [...this.map.values()].sort((a, b) => a.createdAt - b.createdAt);
9
+ }
10
+
11
+ async put(entry: OutboxEntry): Promise<void> {
12
+ this.map.set(entry.id, { ...entry });
13
+ }
14
+
15
+ async delete(id: string): Promise<void> {
16
+ this.map.delete(id);
17
+ }
18
+
19
+ async clear(): Promise<void> {
20
+ this.map.clear();
21
+ }
22
+ }
23
+
24
+ const DB_VERSION = 1;
25
+
26
+ /** IndexedDB-backed outbox storage — durable across reloads and app restarts,
27
+ * so a mutation captured offline survives the tab being closed. */
28
+ export class IndexedDbOutboxStorage implements OutboxStorage {
29
+ private dbPromise: Promise<IDBDatabase> | null = null;
30
+
31
+ constructor(
32
+ private readonly dbName = 'xema-offline-outbox',
33
+ private readonly storeName = 'entries',
34
+ ) {}
35
+
36
+ private open(): Promise<IDBDatabase> {
37
+ if (this.dbPromise) {
38
+ return this.dbPromise;
39
+ }
40
+ this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
41
+ const req = indexedDB.open(this.dbName, DB_VERSION);
42
+ req.onupgradeneeded = () => {
43
+ const db = req.result;
44
+ if (!db.objectStoreNames.contains(this.storeName)) {
45
+ db.createObjectStore(this.storeName, { keyPath: 'id' });
46
+ }
47
+ };
48
+ req.onsuccess = () => resolve(req.result);
49
+ req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
50
+ });
51
+ return this.dbPromise;
52
+ }
53
+
54
+ private async tx<T>(
55
+ mode: IDBTransactionMode,
56
+ run: (store: IDBObjectStore) => IDBRequest<T> | void,
57
+ ): Promise<T | undefined> {
58
+ const db = await this.open();
59
+ return new Promise<T | undefined>((resolve, reject) => {
60
+ const transaction = db.transaction(this.storeName, mode);
61
+ const store = transaction.objectStore(this.storeName);
62
+ let request: IDBRequest<T> | void;
63
+ try {
64
+ request = run(store);
65
+ } catch (err) {
66
+ reject(err);
67
+ return;
68
+ }
69
+ transaction.onabort = () =>
70
+ reject(transaction.error ?? new Error('indexedDB tx aborted'));
71
+ transaction.oncomplete = () =>
72
+ resolve(request ? (request as IDBRequest<T>).result : undefined);
73
+ transaction.onerror = () =>
74
+ reject(transaction.error ?? new Error('indexedDB tx error'));
75
+ });
76
+ }
77
+
78
+ async getAll(): Promise<OutboxEntry[]> {
79
+ const all = (await this.tx<OutboxEntry[]>('readonly', (s) => s.getAll())) ?? [];
80
+ return all.sort((a, b) => a.createdAt - b.createdAt);
81
+ }
82
+
83
+ async put(entry: OutboxEntry): Promise<void> {
84
+ await this.tx('readwrite', (s) => s.put(entry));
85
+ }
86
+
87
+ async delete(id: string): Promise<void> {
88
+ await this.tx('readwrite', (s) => s.delete(id));
89
+ }
90
+
91
+ async clear(): Promise<void> {
92
+ await this.tx('readwrite', (s) => s.clear());
93
+ }
94
+ }
95
+
96
+ /** Pick the best available storage: IndexedDB in a browser, else in-memory
97
+ * (SSR / tests / private-mode fallbacks). */
98
+ export function createDefaultOutboxStorage(): OutboxStorage {
99
+ if (typeof indexedDB !== 'undefined') {
100
+ try {
101
+ return new IndexedDbOutboxStorage();
102
+ } catch {
103
+ // Some privacy modes throw on open — fall back to in-memory.
104
+ }
105
+ }
106
+ return new InMemoryOutboxStorage();
107
+ }
@@ -0,0 +1,77 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // @xemahq/ui-kernel/offline — types
3
+ //
4
+ // A generic offline field-capture kit: an IndexedDB-backed outbox that queues
5
+ // mutations while disconnected and drains them (with retry + idempotency) on
6
+ // reconnect. Provider-agnostic — any mutation (an app-forge record submit, a
7
+ // status change, …) is enqueued by a stable `kind` whose executor is registered
8
+ // once. The motivating case is field/trade-fair capture with no signal.
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ /** Lifecycle of a queued mutation. */
12
+ export enum OutboxEntryStatus {
13
+ /** Waiting to be sent (offline, or not yet attempted). */
14
+ Pending = 'pending',
15
+ /** Currently being sent. */
16
+ Inflight = 'inflight',
17
+ /** Failed transiently; will be retried (attempts < maxAttempts). */
18
+ Retrying = 'retrying',
19
+ /** Exhausted retries — needs manual retry or discard (dead-letter). */
20
+ Failed = 'failed',
21
+ }
22
+
23
+ /** One queued mutation. `payload` is opaque to the outbox; the executor for
24
+ * `kind` interprets it. */
25
+ export interface OutboxEntry<TPayload = unknown> {
26
+ /** Stable entry id (also the default idempotency key). */
27
+ id: string;
28
+ /** Registered executor key (e.g. "app-forge:create-record"). */
29
+ kind: string;
30
+ payload: TPayload;
31
+ /** Sent to the backend so a redelivered mutation is not double-applied. */
32
+ idempotencyKey: string;
33
+ status: OutboxEntryStatus;
34
+ attempts: number;
35
+ createdAt: number;
36
+ updatedAt: number;
37
+ /** Last error message (for the dead-letter UI). */
38
+ lastError?: string;
39
+ /** Optional human label for the pending-items UI. */
40
+ label?: string;
41
+ }
42
+
43
+ /** Persistence backend for the outbox. IndexedDB in the browser; in-memory for
44
+ * SSR / tests. */
45
+ export interface OutboxStorage {
46
+ getAll(): Promise<OutboxEntry[]>;
47
+ put(entry: OutboxEntry): Promise<void>;
48
+ delete(id: string): Promise<void>;
49
+ clear(): Promise<void>;
50
+ }
51
+
52
+ /** Context passed to an executor when a queued mutation is sent. */
53
+ export interface OutboxExecuteContext {
54
+ /** Pass to the backend (e.g. as an `Idempotency-Key` header). */
55
+ idempotencyKey: string;
56
+ /** How many times this entry has now been attempted (1-based). */
57
+ attempt: number;
58
+ entryId: string;
59
+ }
60
+
61
+ /** Sends one queued mutation. Throwing schedules a retry (see
62
+ * {@link OutboxOptions.isRetriable}). */
63
+ export type OutboxExecutor<TPayload = unknown> = (
64
+ payload: TPayload,
65
+ ctx: OutboxExecuteContext,
66
+ ) => Promise<void>;
67
+
68
+ /** Snapshot of outbox state for UI (offline badge, pending count, dead-letter). */
69
+ export interface OutboxSnapshot {
70
+ entries: OutboxEntry[];
71
+ pendingCount: number;
72
+ failedCount: number;
73
+ /** True while a drain is in progress. */
74
+ draining: boolean;
75
+ /** Current connectivity (from the outbox's online controller). */
76
+ online: boolean;
77
+ }
@@ -20,7 +20,7 @@
20
20
  // ═══════════════════════════════════════════════════════════════════════════
21
21
  import * as React from 'react';
22
22
 
23
- import type { BrandingConfig } from '@xemahq/kernel-contracts/app-runtime';
23
+ import type { BrandingConfig } from '@xemahq/kernel-contracts/app-platform';
24
24
 
25
25
  import { cn } from '../cn';
26
26
 
@@ -57,3 +57,25 @@ describe('fieldSpecToZod', () => {
57
57
  expect(fieldSpecToZod(fields).safeParse({ title: 'Hi', status: 'open', tags: [{ name: '' }] }).success).toBe(false);
58
58
  });
59
59
  });
60
+
61
+ describe('media fields (image/file/audio)', () => {
62
+ const media: CaptureFieldSpec[] = [
63
+ { key: 'photo', label: 'Photo', type: CaptureFieldType.Image, required: true },
64
+ { key: 'attachment', label: 'Attachment', type: CaptureFieldType.File },
65
+ { key: 'memo', label: 'Voice memo', type: CaptureFieldType.Audio },
66
+ ];
67
+ const dataUrl = 'data:image/png;base64,AAAA';
68
+
69
+ it('accepts a captured data-URL for a required media field', () => {
70
+ expect(fieldSpecToZod(media).safeParse({ photo: dataUrl }).success).toBe(true);
71
+ });
72
+
73
+ it('rejects an empty required media field', () => {
74
+ expect(fieldSpecToZod(media).safeParse({ photo: '' }).success).toBe(false);
75
+ expect(fieldSpecToZod(media).safeParse({}).success).toBe(false);
76
+ });
77
+
78
+ it('allows omitted optional media fields', () => {
79
+ expect(fieldSpecToZod(media).safeParse({ photo: dataUrl }).success).toBe(true);
80
+ });
81
+ })
@@ -54,7 +54,18 @@ function fieldSchema(field: CaptureFieldSpec): z.ZodTypeAny {
54
54
  base = arr;
55
55
  } else {
56
56
  base = scalarSchema(field);
57
- if (field.required && (field.type === CaptureFieldType.String || field.type === CaptureFieldType.Text || field.type === CaptureFieldType.User || field.type === CaptureFieldType.Reference || field.type === CaptureFieldType.Datetime)) {
57
+ if (
58
+ field.required &&
59
+ (field.type === CaptureFieldType.String ||
60
+ field.type === CaptureFieldType.Text ||
61
+ field.type === CaptureFieldType.User ||
62
+ field.type === CaptureFieldType.Reference ||
63
+ field.type === CaptureFieldType.Datetime ||
64
+ // Media fields carry a data-URL string; required = a capture was made.
65
+ field.type === CaptureFieldType.Image ||
66
+ field.type === CaptureFieldType.File ||
67
+ field.type === CaptureFieldType.Audio)
68
+ ) {
58
69
  base = (base as z.ZodString).min(1, { message: `${field.label} is required` });
59
70
  }
60
71
  }
@@ -22,9 +22,12 @@
22
22
  * App Platform pass straight through. `object`/`list` are the structural
23
23
  * composites this kit adds for nested + repeatable capture.
24
24
  *
25
- * The string VALUES are the contract — keep them identical to app-forge's enum
26
- * (parity is checked there by `check-app-enum-parity.mjs`). Adding a value here
27
- * that app-forge lacks is fine (superset); changing an existing value is not.
25
+ * The string VALUES are the contract — keep the first ten identical to
26
+ * `AppFieldType` in `@xemahq/kernel-contracts/app-definition` (the single
27
+ * source of truth for App Platform field types). Adding a value here that
28
+ * `AppFieldType` lacks is fine (superset); changing an existing shared value is
29
+ * not. This kit can't import the enum (it adds members), so the shared values
30
+ * are mirrored deliberately.
28
31
  */
29
32
  export enum CaptureFieldType {
30
33
  String = 'string',
@@ -39,6 +42,14 @@ export enum CaptureFieldType {
39
42
  User = 'user',
40
43
  Object = 'object',
41
44
  List = 'list',
45
+ // ── Media capture (field-capture kit) ──
46
+ // Value is a data-URL string (base64), so it stays JSON-serializable and
47
+ // flows unchanged through the record model + the offline outbox. `Image`
48
+ // opens the device camera on mobile (`capture`), `File` attaches any file,
49
+ // `Audio` records a voice memo via MediaRecorder.
50
+ Image = 'image',
51
+ File = 'file',
52
+ Audio = 'audio',
42
53
  }
43
54
 
44
55
  /**
@@ -69,6 +80,16 @@ export interface CaptureFieldSpec {
69
80
  /** `number`/`decimal`: numeric bounds. `list`: min/max item count. */
70
81
  readonly min?: number;
71
82
  readonly max?: number;
83
+ /**
84
+ * `image`/`file`/`audio`: the `accept` filter for the file picker (e.g.
85
+ * `image/*`, `application/pdf`). Defaults per type when omitted.
86
+ */
87
+ readonly accept?: string;
88
+ /**
89
+ * `image`/`file`: the HTML `capture` hint — `environment` opens the rear
90
+ * camera, `user` the front camera. Omitted = a normal file picker.
91
+ */
92
+ readonly capture?: 'user' | 'environment';
72
93
  /**
73
94
  * Optional section grouping key for FLAT `CaptureFieldSpec[]` schemas. When a
74
95
  * schema is expressed as {@link CaptureSection}[] this is ignored (grouping is
@@ -8,7 +8,7 @@
8
8
  // seam app-forge uses to plug its record-backed `ReferenceSelect`).
9
9
  // ═══════════════════════════════════════════════════════════════════════════
10
10
  import { Plus, Trash2 } from 'lucide-react';
11
- import { type ReactNode } from 'react';
11
+ import { useCallback, useRef, useState, type ReactNode } from 'react';
12
12
 
13
13
  import { cn } from '../cn';
14
14
  import { Button } from '../primitives/button';
@@ -98,12 +98,140 @@ export function SchemaFieldInput(props: SchemaFieldInputProps) {
98
98
  return <ObjectInput {...props} />;
99
99
  case CaptureFieldType.List:
100
100
  return <ListInput {...props} />;
101
+ case CaptureFieldType.Image:
102
+ case CaptureFieldType.File:
103
+ return <MediaFieldInput {...props} />;
104
+ case CaptureFieldType.Audio:
105
+ return <AudioFieldInput {...props} />;
101
106
  // string (and any unknown) — plain single-line input.
102
107
  default:
103
108
  return <Input id={inputId} value={asString(value)} onChange={(e) => onChange(e.target.value)} disabled={disabled} placeholder={field.placeholder} />;
104
109
  }
105
110
  }
106
111
 
112
+ const FILE_INPUT_CLASS =
113
+ 'block w-full text-body-2 text-foreground file:mr-3 file:rounded-md file:border-0 file:bg-secondary file:px-3 file:py-1.5 file:text-body-2 file:text-secondary-foreground hover:file:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50';
114
+
115
+ /** Read a File/Blob as a base64 data-URL (JSON-serializable, offline-safe). */
116
+ function readAsDataUrl(file: Blob, onDone: (dataUrl: string) => void): void {
117
+ const reader = new FileReader();
118
+ reader.onload = () =>
119
+ onDone(typeof reader.result === 'string' ? reader.result : '');
120
+ reader.readAsDataURL(file);
121
+ }
122
+
123
+ /**
124
+ * `image`/`file` capture. On mobile, `field.capture` makes an image field open
125
+ * the camera directly. The value is a data-URL so it stays JSON-serializable and
126
+ * flows unchanged through the record model + offline outbox.
127
+ */
128
+ function MediaFieldInput({ field, value, onChange, disabled, path }: SchemaFieldInputProps) {
129
+ const inputId = `field-${path}`;
130
+ const current = asString(value);
131
+ const isImage = field.type === CaptureFieldType.Image;
132
+ const accept = field.accept ?? (isImage ? 'image/*' : undefined);
133
+ return (
134
+ <div className="mt-1 space-y-2">
135
+ <input
136
+ id={inputId}
137
+ type="file"
138
+ className={FILE_INPUT_CLASS}
139
+ disabled={disabled}
140
+ {...(accept ? { accept } : {})}
141
+ {...(field.capture ? { capture: field.capture } : {})}
142
+ onChange={(e) => {
143
+ const file = e.target.files?.[0];
144
+ if (file) {
145
+ readAsDataUrl(file, onChange);
146
+ }
147
+ }}
148
+ />
149
+ {current ? (
150
+ <div className="flex items-center gap-2">
151
+ {isImage && current.startsWith('data:image') ? (
152
+ <img src={current} alt={field.label} className="max-h-32 rounded-md border border-input" />
153
+ ) : (
154
+ <span className="truncate text-body-2 text-muted-foreground">Attached</span>
155
+ )}
156
+ <Button type="button" variant="ghost" size="sm" onClick={() => onChange('')} disabled={disabled}>
157
+ Clear
158
+ </Button>
159
+ </div>
160
+ ) : null}
161
+ </div>
162
+ );
163
+ }
164
+
165
+ /**
166
+ * `audio` capture — records a voice memo via MediaRecorder and stores it as a
167
+ * data-URL. Browser APIs are touched only inside the record handler (SSR-safe).
168
+ */
169
+ function AudioFieldInput({ value, onChange, disabled, path }: SchemaFieldInputProps) {
170
+ const inputId = `field-${path}`;
171
+ const current = asString(value);
172
+ const [recording, setRecording] = useState(false);
173
+ const [error, setError] = useState<string | null>(null);
174
+ const recorderRef = useRef<MediaRecorder | null>(null);
175
+ const chunksRef = useRef<Blob[]>([]);
176
+
177
+ const start = useCallback(async () => {
178
+ setError(null);
179
+ try {
180
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
181
+ const recorder = new MediaRecorder(stream);
182
+ chunksRef.current = [];
183
+ recorder.ondataavailable = (e) => {
184
+ if (e.data.size > 0) {
185
+ chunksRef.current.push(e.data);
186
+ }
187
+ };
188
+ recorder.onstop = () => {
189
+ for (const track of stream.getTracks()) {
190
+ track.stop();
191
+ }
192
+ readAsDataUrl(
193
+ new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' }),
194
+ onChange,
195
+ );
196
+ };
197
+ recorderRef.current = recorder;
198
+ recorder.start();
199
+ setRecording(true);
200
+ } catch {
201
+ setError('Microphone unavailable or permission denied.');
202
+ }
203
+ }, [onChange]);
204
+
205
+ const stop = useCallback(() => {
206
+ recorderRef.current?.stop();
207
+ recorderRef.current = null;
208
+ setRecording(false);
209
+ }, []);
210
+
211
+ return (
212
+ <div className="mt-1 space-y-2">
213
+ <div className="flex items-center gap-2">
214
+ {recording ? (
215
+ <Button type="button" variant="secondary" size="sm" onClick={stop} disabled={disabled}>
216
+ Stop
217
+ </Button>
218
+ ) : (
219
+ <Button type="button" variant="secondary" size="sm" onClick={() => void start()} disabled={disabled}>
220
+ {current ? 'Re-record' : 'Record'}
221
+ </Button>
222
+ )}
223
+ {current ? (
224
+ <Button type="button" variant="ghost" size="sm" onClick={() => onChange('')} disabled={disabled}>
225
+ Clear
226
+ </Button>
227
+ ) : null}
228
+ </div>
229
+ {error ? <p className="text-body-2 text-destructive">{error}</p> : null}
230
+ {current && !recording ? <audio id={inputId} controls src={current} className="w-full" /> : null}
231
+ </div>
232
+ );
233
+ }
234
+
107
235
  /** Renders one label + input + error block for a nested field. */
108
236
  function NestedField(props: SchemaFieldInputProps) {
109
237
  const { field, path, errors } = props;
@@ -1,93 +0,0 @@
1
- // Test suite for defineWebBiome's define-time scope/access validation and the
2
- // chrome default. Runs on Node's native test runner against the COMPILED dist
3
- // (CommonJS), so the package needs no jest/vitest/ts-jest toolchain — the
4
- // `test` script builds first, then runs this file with `node --test`.
5
- //
6
- // Covered:
7
- // 1. A `public` page with access `'member'` (or omitted) throws at define time.
8
- // 2. A `public` + access `'public'` page is accepted; its route carries no
9
- // nav item and mounts with access `'public'`.
10
- // 3. A non-public page declaring access `'public'` throws at define time.
11
- // 4. `chrome` defaults to `'host'` when a page omits it; `'none'` passes through.
12
-
13
- 'use strict';
14
-
15
- const test = require('node:test');
16
- const assert = require('node:assert/strict');
17
-
18
- const { defineWebBiome } = require('../../../dist/lib/biome-host/define-web-biome.js');
19
-
20
- const load = () => Promise.resolve({ default: () => null });
21
- const bridge = /** @type {any} */ ({});
22
-
23
- test('rejects a public page whose access is not public (member default)', () => {
24
- assert.throws(
25
- () =>
26
- defineWebBiome({
27
- id: 'test-biome',
28
- displayName: 'Test',
29
- pages: [{ slug: 'ext/:id', label: 'Ext', scope: 'public', load }],
30
- }),
31
- /a 'public' scope page MUST declare access: 'public'/,
32
- );
33
- });
34
-
35
- test('rejects a public page with an explicit non-public access tier', () => {
36
- assert.throws(
37
- () =>
38
- defineWebBiome({
39
- id: 'test-biome',
40
- displayName: 'Test',
41
- pages: [
42
- { slug: 'ext/:id', label: 'Ext', scope: 'public', access: 'org-admin', load },
43
- ],
44
- }),
45
- /a 'public' scope page MUST declare access: 'public'/,
46
- );
47
- });
48
-
49
- test('rejects a non-public page that declares access public', () => {
50
- assert.throws(
51
- () =>
52
- defineWebBiome({
53
- id: 'test-biome',
54
- displayName: 'Test',
55
- pages: [{ slug: 'system/x', label: 'X', access: 'public', load }],
56
- }),
57
- /access: 'public' is only valid on a 'public' scope page/,
58
- );
59
- });
60
-
61
- test('accepts a public + access:public page and emits no nav item', () => {
62
- const factory = defineWebBiome({
63
- id: 'test-biome',
64
- displayName: 'Test',
65
- pages: [
66
- { slug: 'ext/:id', label: 'Ext', scope: 'public', access: 'public', load },
67
- ],
68
- });
69
- const biome = factory(bridge);
70
- assert.equal(biome.routes.length, 1);
71
- const route = biome.routes[0];
72
- assert.equal(route.path, 'ext/:id');
73
- assert.equal(route.access, 'public');
74
- assert.equal(route.projectScoped, false);
75
- // Public routes never enter the authenticated app rail.
76
- assert.equal(biome.navItems.length, 0);
77
- });
78
-
79
- test('chrome defaults to host and none passes through', () => {
80
- const factory = defineWebBiome({
81
- id: 'test-biome',
82
- displayName: 'Test',
83
- pages: [
84
- { slug: 'system/a', label: 'A', load },
85
- { slug: 'system/b', label: 'B', chrome: 'none', load },
86
- ],
87
- });
88
- const biome = factory(bridge);
89
- const a = biome.routes.find((r) => r.path === 'system/a');
90
- const b = biome.routes.find((r) => r.path === 'system/b');
91
- assert.equal(a.chrome, 'host');
92
- assert.equal(b.chrome, 'none');
93
- });