@north-light/crouter 0.3.192 → 0.3.193
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/dist/api/client.d.ts +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +22 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +15 -28
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +32 -0
- package/dist/core/bash-jobs.js +72 -1
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +89 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- package/dist/pages/bundle.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- package/dist/core/human/deck-schema.js +0 -92
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -14,10 +14,42 @@ export interface BashJobPaths {
|
|
|
14
14
|
jobPgid: string;
|
|
15
15
|
}
|
|
16
16
|
export declare function bashJobsDir(contextDir: string): string;
|
|
17
|
+
/** Whether a job id can safely be used as one path segment. Generated ids are
|
|
18
|
+
* numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
|
|
19
|
+
* older file-backed ids readable without allowing path or request delimiters. */
|
|
20
|
+
export declare function isSafeBashJobId(jobId: string): boolean;
|
|
17
21
|
export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
|
|
18
22
|
/** The job's supervisor process group, or undefined for a job started before
|
|
19
23
|
* pgid was persisted (or a partially-written dir). */
|
|
20
24
|
export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
|
|
25
|
+
/** Whether a persisted job supervisor process group is still present. EPERM
|
|
26
|
+
* means the group exists but cannot be signaled by this process; it is still
|
|
27
|
+
* live for the status surface. */
|
|
28
|
+
export declare function isBashJobProcessGroupAlive(pgid: number): boolean;
|
|
29
|
+
export type StopBackgroundBashJobResult = {
|
|
30
|
+
kind: 'stopped';
|
|
31
|
+
signaled: boolean;
|
|
32
|
+
pgid: number;
|
|
33
|
+
logPath: string;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'missing';
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'retired';
|
|
38
|
+
logPath: string;
|
|
39
|
+
} | {
|
|
40
|
+
kind: 'not-stoppable';
|
|
41
|
+
logPath: string;
|
|
42
|
+
};
|
|
43
|
+
/** Stop one live background job using the same process-group and sentinel
|
|
44
|
+
* semantics as `crtr node bash kill`. The caller owns the user-facing
|
|
45
|
+
* notification; this primitive owns only the job control-plane mutation.
|
|
46
|
+
*
|
|
47
|
+
* Only a job in the live-background state the roster projects (job.bg
|
|
48
|
+
* present, job.exit absent) may be signaled. Job directories persist after
|
|
49
|
+
* the supervisor exits, so a retired, still-foreground, or already-stopped
|
|
50
|
+
* job would otherwise have its recorded pgid signaled long after the OS
|
|
51
|
+
* could have reused that id for an unrelated process group. */
|
|
52
|
+
export declare function stopBackgroundBashJob(contextDir: string, jobId: string): Promise<StopBackgroundBashJobResult>;
|
|
21
53
|
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
22
54
|
* rather than the whole file, which for a long-running job can be enormous. */
|
|
23
55
|
export declare function tailJobLog(logPath: string, count: number): string[];
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -3,11 +3,20 @@
|
|
|
3
3
|
// job.bg, while foreground completion renames it to job.done. The detached
|
|
4
4
|
// supervisor can therefore outlive pi and still decide whether to report exit.
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
|
-
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync } from 'node:fs';
|
|
6
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
export function bashJobsDir(contextDir) {
|
|
9
9
|
return join(contextDir, 'jobs');
|
|
10
10
|
}
|
|
11
|
+
/** Whether a job id can safely be used as one path segment. Generated ids are
|
|
12
|
+
* numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
|
|
13
|
+
* older file-backed ids readable without allowing path or request delimiters. */
|
|
14
|
+
export function isSafeBashJobId(jobId) {
|
|
15
|
+
return typeof jobId === 'string'
|
|
16
|
+
&& jobId !== '' && jobId !== '.' && jobId !== '..'
|
|
17
|
+
&& /^[A-Za-z0-9._~-]+$/u.test(jobId)
|
|
18
|
+
&& Buffer.byteLength(jobId, 'utf8') <= 128;
|
|
19
|
+
}
|
|
11
20
|
export function bashJobPaths(contextDir, jobId) {
|
|
12
21
|
const dir = join(bashJobsDir(contextDir), jobId);
|
|
13
22
|
return {
|
|
@@ -33,6 +42,68 @@ export function readJobPgid(contextDir, jobId) {
|
|
|
33
42
|
return undefined;
|
|
34
43
|
}
|
|
35
44
|
}
|
|
45
|
+
/** Whether a persisted job supervisor process group is still present. EPERM
|
|
46
|
+
* means the group exists but cannot be signaled by this process; it is still
|
|
47
|
+
* live for the status surface. */
|
|
48
|
+
export function isBashJobProcessGroupAlive(pgid) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(-pgid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return err.code === 'EPERM';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Stop one live background job using the same process-group and sentinel
|
|
58
|
+
* semantics as `crtr node bash kill`. The caller owns the user-facing
|
|
59
|
+
* notification; this primitive owns only the job control-plane mutation.
|
|
60
|
+
*
|
|
61
|
+
* Only a job in the live-background state the roster projects (job.bg
|
|
62
|
+
* present, job.exit absent) may be signaled. Job directories persist after
|
|
63
|
+
* the supervisor exits, so a retired, still-foreground, or already-stopped
|
|
64
|
+
* job would otherwise have its recorded pgid signaled long after the OS
|
|
65
|
+
* could have reused that id for an unrelated process group. */
|
|
66
|
+
export async function stopBackgroundBashJob(contextDir, jobId) {
|
|
67
|
+
const paths = bashJobPaths(contextDir, jobId);
|
|
68
|
+
if (!existsSync(paths.dir))
|
|
69
|
+
return { kind: 'missing' };
|
|
70
|
+
if (!existsSync(paths.jobBg) || existsSync(paths.jobExit))
|
|
71
|
+
return { kind: 'retired', logPath: paths.jobLog };
|
|
72
|
+
const pgid = readJobPgid(contextDir, jobId);
|
|
73
|
+
if (pgid === undefined)
|
|
74
|
+
return { kind: 'not-stoppable', logPath: paths.jobLog };
|
|
75
|
+
// Claim the stop by creating job.exit exclusively, before signaling. The
|
|
76
|
+
// loser of two concurrent stops — and a stop that races the supervisor's own
|
|
77
|
+
// completion — reports retired instead of signaling a departed group and
|
|
78
|
+
// sending the owning node a second "was stopped" notice.
|
|
79
|
+
try {
|
|
80
|
+
const fd = openSync(paths.jobExit, 'wx');
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(fd, '143\n');
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
closeSync(fd);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err.code === 'EEXIST')
|
|
90
|
+
return { kind: 'retired', logPath: paths.jobLog };
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
let signaled = true;
|
|
94
|
+
try {
|
|
95
|
+
process.kill(-pgid, 'SIGTERM');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
signaled = false;
|
|
99
|
+
}
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
101
|
+
try {
|
|
102
|
+
process.kill(-pgid, 'SIGKILL');
|
|
103
|
+
}
|
|
104
|
+
catch { /* already gone */ }
|
|
105
|
+
return { kind: 'stopped', signaled, pgid, logPath: paths.jobLog };
|
|
106
|
+
}
|
|
36
107
|
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
37
108
|
* rather than the whole file, which for a long-running job can be enormous. */
|
|
38
109
|
export function tailJobLog(logPath, count) {
|
|
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
|
|
|
4
4
|
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { atomicWriteJson,
|
|
7
|
+
import { atomicWriteJson, pageManifestPath } from '../../human/convention.js';
|
|
8
8
|
import { createNode } from '../canvas.js';
|
|
9
9
|
import { closeDb } from '../db.js';
|
|
10
10
|
import { nodeDir } from '../paths.js';
|
|
@@ -22,12 +22,18 @@ function meta(id, cwd, extra) {
|
|
|
22
22
|
...extra,
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
-
function
|
|
25
|
+
function seedPage(bridgeId) {
|
|
26
26
|
const dir = nodeDir(bridgeId);
|
|
27
27
|
mkdirSync(dir, { recursive: true });
|
|
28
|
-
atomicWriteJson(
|
|
28
|
+
atomicWriteJson(pageManifestPath(dir), {
|
|
29
|
+
schema: 'crtr.page/v1',
|
|
30
|
+
dialect: 'md',
|
|
29
31
|
title: bridgeId,
|
|
30
|
-
|
|
32
|
+
subtitle: 'Acknowledge this ticket so the request can proceed.',
|
|
33
|
+
placement: 'inline',
|
|
34
|
+
document: 'page.md',
|
|
35
|
+
steps: 1,
|
|
36
|
+
slots: [],
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
39
|
function bridge(id, parent, cwd) {
|
|
@@ -61,10 +67,10 @@ test('ticketCountsForNodes follows bridge parents even when asking nodes share a
|
|
|
61
67
|
bridge('ha2', 'a', sharedCwd);
|
|
62
68
|
bridge('hb', 'b', sharedCwd);
|
|
63
69
|
bridge('hc', 'c', soloCwd);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
seedPage('ha1');
|
|
71
|
+
seedPage('ha2');
|
|
72
|
+
seedPage('hb');
|
|
73
|
+
seedPage('hc');
|
|
68
74
|
assert.deepEqual(ticketCountsForNodes(['a', 'b', 'c']), { a: 2, b: 1, c: 1 });
|
|
69
75
|
});
|
|
70
76
|
test('pendingTicketsForView returns the asking node rather than a cwd bucket', () => {
|
|
@@ -72,8 +78,8 @@ test('pendingTicketsForView returns the asking node rather than a cwd bucket', (
|
|
|
72
78
|
createNode(meta('b', sharedCwd));
|
|
73
79
|
bridge('ha', 'a', sharedCwd);
|
|
74
80
|
bridge('hb', 'b', sharedCwd);
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
seedPage('ha');
|
|
82
|
+
seedPage('hb');
|
|
77
83
|
assert.deepEqual(pendingTicketsForView('a').map(({ node_id, count }) => ({ node_id, count })), [
|
|
78
84
|
{ node_id: 'a', count: 1 },
|
|
79
85
|
]);
|
|
@@ -19,7 +19,7 @@ import { createServer } from 'node:http';
|
|
|
19
19
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { tmpdir } from 'node:os';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
-
import { atomicWriteJson,
|
|
22
|
+
import { atomicWriteJson, pageManifestPath } from '#core/human/convention';
|
|
23
23
|
import { RemoteCanvasSource } from '../remote-canvas-source.js';
|
|
24
24
|
import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
|
|
25
25
|
import { createNode } from '../canvas.js';
|
|
@@ -113,9 +113,15 @@ function seedPoisonedLocalState() {
|
|
|
113
113
|
createNode(meta('a', { cwd: localCwd }));
|
|
114
114
|
const askDir = nodeDir('poison-ask');
|
|
115
115
|
mkdirSync(askDir, { recursive: true });
|
|
116
|
-
atomicWriteJson(
|
|
116
|
+
atomicWriteJson(pageManifestPath(askDir), {
|
|
117
|
+
schema: 'crtr.page/v1',
|
|
118
|
+
dialect: 'md',
|
|
117
119
|
title: 'poison',
|
|
118
|
-
|
|
120
|
+
subtitle: 'This local ticket must not affect the remote rendering.',
|
|
121
|
+
placement: 'inline',
|
|
122
|
+
document: 'page.md',
|
|
123
|
+
steps: 1,
|
|
124
|
+
slots: [],
|
|
119
125
|
});
|
|
120
126
|
// A local focus + broker viewer — poison both `viewed` signals.
|
|
121
127
|
openFocusRow('poison-focus', null, null, 'a');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Every ticket is a direct child of the single registered `<canvas>/nodes` root,
|
|
4
4
|
// named for its terminal human bridge node. The bridge's parent is the asking
|
|
5
5
|
// node, so storage identity and attribution come from the canvas graph rather
|
|
6
|
-
// than duplicated attribution fields in
|
|
6
|
+
// than duplicated attribution fields in page/review data.
|
|
7
7
|
import { basename } from 'node:path';
|
|
8
8
|
import { scanInbox } from '../human/scan.js';
|
|
9
9
|
import { filterTerminalReviewTickets } from '../review/ticket-filter.js';
|
|
@@ -55,7 +55,7 @@ export async function runBrowse(opts) {
|
|
|
55
55
|
process.stdout.write(await renderForestFromSource(source) + '\n');
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
|
-
// Snapshot the canvas. Drop kind:'human' control-plane
|
|
58
|
+
// Snapshot the canvas. Drop kind:'human' control-plane pages — they have no pi
|
|
59
59
|
// session, so `surface node focus` refuses them; they are never a navigation/resume
|
|
60
60
|
// target (mirrors the surface-node focus guard).
|
|
61
61
|
let rows;
|
|
@@ -3,7 +3,7 @@ import type { NodeMeta } from './index.js';
|
|
|
3
3
|
export { PI_MAX_WIDGET_LINES, VIEWPORT_FALLBACK_ROWS, ESC, RESET, BOLD, DIM, REVERSE, BG_ATTACHED, GREEN, RED, YELLOW, CYAN, GRAY, nodeGlyph, visibleWidth, truncate, fillWidth, fillBar, readTelemetry, fmtTokens, tokensCell, activityCell, cycleBadge, navLabel, shortId, askBadge, liveBelowBadge, isAttached, hasBrokerViewers, fetchAsksMap, graphWidgetBudget, GRAPH_HINT, type Telemetry, type FlatRow, type FoldState, type SubtreeActivity, } from './nav-render.js';
|
|
4
4
|
/** First manager (by created) — the UP step for the ancestry spine. */
|
|
5
5
|
export declare function managerOf(id: string): string | undefined;
|
|
6
|
-
/** A kind:'human' node is a control-plane ASK (a
|
|
6
|
+
/** A kind:'human' node is a control-plane ASK (a page on the human's
|
|
7
7
|
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
8
8
|
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
9
9
|
* already rides the ⚑ badge on the ASKING node (attention.ts follows the
|
|
@@ -73,7 +73,7 @@ function mgrsOf(id) {
|
|
|
73
73
|
export function managerOf(id) {
|
|
74
74
|
return mgrsOf(id)[0]?.node_id;
|
|
75
75
|
}
|
|
76
|
-
/** A kind:'human' node is a control-plane ASK (a
|
|
76
|
+
/** A kind:'human' node is a control-plane ASK (a page on the human's
|
|
77
77
|
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
78
78
|
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
79
79
|
* already rides the ⚑ badge on the ASKING node (attention.ts follows the
|
|
@@ -208,7 +208,7 @@ export class RemoteCanvasSource {
|
|
|
208
208
|
}
|
|
209
209
|
async ticketCountsForView(root) {
|
|
210
210
|
// The `canvas attention map` transport is unchanged; its `counts` values now
|
|
211
|
-
// mean all pending human tickets (
|
|
211
|
+
// mean all pending human tickets (page/review), not just asks.
|
|
212
212
|
const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
|
|
213
213
|
return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
|
|
214
214
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -10,6 +10,7 @@ import { profileRoot } from './profiles/manifest.js';
|
|
|
10
10
|
import { withExclusiveDirectoryLock } from './exclusive-lock.js';
|
|
11
11
|
import { SCOPE_CONFIG_KEYS } from './user-settings.js';
|
|
12
12
|
import { normalizeWorkingGerunds } from '../shared/working-activity.js';
|
|
13
|
+
import { normalizePageComponents } from './human/page-catalog.js';
|
|
13
14
|
function configPathFor(root) {
|
|
14
15
|
return join(root, CONFIG_FILE);
|
|
15
16
|
}
|
|
@@ -478,6 +479,8 @@ function mergeConfig(partial) {
|
|
|
478
479
|
? [...defaults.working_gerunds]
|
|
479
480
|
: normalizeWorkingGerunds(partial.working_gerunds);
|
|
480
481
|
const whip_mode = typeof partial.whip_mode === 'boolean' ? partial.whip_mode : defaults.whip_mode;
|
|
482
|
+
const html_pages = typeof partial.html_pages === 'boolean' ? partial.html_pages : defaults.html_pages;
|
|
483
|
+
const page_components = normalizePageComponents(partial.page_components);
|
|
481
484
|
const whip_messages = normalizeWhipMessages(partial.whip_messages);
|
|
482
485
|
const mouse_mode_default = MOUSE_MODE_DEFAULTS.includes(partial.mouse_mode_default)
|
|
483
486
|
? partial.mouse_mode_default
|
|
@@ -497,7 +500,7 @@ function mergeConfig(partial) {
|
|
|
497
500
|
const kinds = mergeKinds(partial.kinds);
|
|
498
501
|
const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
|
|
499
502
|
const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
|
|
500
|
-
return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, whip_messages, mouse_mode_default, live_cycles, condensed_history, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
|
|
503
|
+
return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, html_pages, page_components, whip_messages, mouse_mode_default, live_cycles, condensed_history, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
|
|
501
504
|
}
|
|
502
505
|
/** Raw (un-defaulted) partial config for one scope, or null if the scope has
|
|
503
506
|
* no root or no config.json. Used by `readMergedLaunchConfig` to layer
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Two properties of the default page template whose absence would be SILENT —
|
|
2
|
+
// every ordinary page still renders and still looks right when either breaks.
|
|
3
|
+
// Nothing else about rendering is pinned here; prose shape is the renderer's
|
|
4
|
+
// own business and a change there is visible the moment anyone looks at a page.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import { derivePageManifest } from '../page.js';
|
|
11
|
+
import { renderPageHtml } from '../page-render.js';
|
|
12
|
+
function render(name, source) {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-test-'));
|
|
14
|
+
try {
|
|
15
|
+
const path = join(dir, name);
|
|
16
|
+
writeFileSync(path, source);
|
|
17
|
+
return renderPageHtml(derivePageManifest(path, name.endsWith('.md') ? 'md' : 'html'), readFileSync(path, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
test('a config value carrying script markup cannot end its own <script> element', () => {
|
|
24
|
+
const html = render('page.md', [
|
|
25
|
+
'---', 'title: Escaping', '---', '',
|
|
26
|
+
'Pick one.', '',
|
|
27
|
+
'```slot:options',
|
|
28
|
+
'{"id":"esc","mode":"single","options":[{"id":"a","label":"</script><script>x</script>"},{"id":"b","label":"<!-- c -->"}]}',
|
|
29
|
+
'```', '',
|
|
30
|
+
].join('\n'));
|
|
31
|
+
const config = /<crtr-options slot-id="esc"><script type="application\/json">([\s\S]*?)<\/script>/.exec(html)?.[1];
|
|
32
|
+
assert.ok(config !== undefined, `no options config in payload:\n${html}`);
|
|
33
|
+
// The mutation this catches: dropping the `<` escape for a plain JSON.stringify.
|
|
34
|
+
assert.ok(!config.includes('<'), `config text still carries a raw "<": ${config}`);
|
|
35
|
+
assert.equal(JSON.parse(config).options[0].label, '</script><script>x</script>');
|
|
36
|
+
});
|
|
37
|
+
test('an authored html slot id is mirrored to the slot-id the runtime reads', () => {
|
|
38
|
+
const html = render('page.html', [
|
|
39
|
+
'<!doctype html><html><head><meta name="crtr-page-title" content="Sign-off"></head><body>',
|
|
40
|
+
'<crtr-pages><crtr-page label="Window">',
|
|
41
|
+
'<crtr-options id="window"><script type="application/json">{"mode":"single","options":[{"id":"sat","label":"Saturday"}]}</script></crtr-options>',
|
|
42
|
+
'</crtr-page></crtr-pages>',
|
|
43
|
+
'</body></html>',
|
|
44
|
+
].join('\n'));
|
|
45
|
+
// Without this, every element mounts and renders and no response ever
|
|
46
|
+
// reaches the host, because `src/pages` keys responses by `slot-id`.
|
|
47
|
+
assert.match(html, /<crtr-options id="window" slot-id="window">/);
|
|
48
|
+
assert.match(html, /<crtr-page label="Window">/);
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
const home = mkdtempSync(join(tmpdir(), 'crtr-page-tickets-'));
|
|
7
|
+
process.env['CRTR_HOME'] = home;
|
|
8
|
+
const { atomicWriteJson, pageManifestPath, pagePath, progressPath, responsePath } = await import('../convention.js');
|
|
9
|
+
const { claimTicket } = await import('../claim.js');
|
|
10
|
+
const { scanInbox, scanPageHistory } = await import('../scan.js');
|
|
11
|
+
const { ticketDir } = await import('../root.js');
|
|
12
|
+
const { cancelTicket, completePage, readPageProgress, readPageTicket, readTicketResult, submitPage, takeoverAndCompletePage, writePageProgress, } = await import('../tickets.js');
|
|
13
|
+
const sources = mkdtempSync(join(tmpdir(), 'crtr-page-sources-'));
|
|
14
|
+
function sourceFile(name, body) {
|
|
15
|
+
const path = join(sources, name);
|
|
16
|
+
writeFileSync(path, body);
|
|
17
|
+
return path;
|
|
18
|
+
}
|
|
19
|
+
const askPage = sourceFile('ask.md', `---
|
|
20
|
+
title: Rollout scope
|
|
21
|
+
subtitle: Pick tonight's rollout
|
|
22
|
+
placement: inline
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Which rollout do you want?
|
|
26
|
+
|
|
27
|
+
\`\`\`slot:options
|
|
28
|
+
{"id": "rollout", "mode": "single", "options": [{"id": "canary", "label": "Canary first"}, {"id": "full", "label": "Full"}]}
|
|
29
|
+
\`\`\`
|
|
30
|
+
`);
|
|
31
|
+
const displayPage = sourceFile('display.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Nightly report"></head><body><h1>All green</h1></body></html>');
|
|
32
|
+
function submit(id, source, dialect, blockedSince, nodeId = 'node-1') {
|
|
33
|
+
return submitPage({ dir: ticketDir(id), sourceFile: source, dialect, source: { nodeId, blockedSince } }).dir;
|
|
34
|
+
}
|
|
35
|
+
test('submitPage publishes the authored source, the derived manifest and the blockedSince stamp', () => {
|
|
36
|
+
const submitted = submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'md', source: { nodeId: 'node-1' } });
|
|
37
|
+
assert.equal(submitted.kind, 'page');
|
|
38
|
+
assert.equal(submitted.id, 'ask');
|
|
39
|
+
assert.equal(readFileSync(pagePath(submitted.dir, 'md'), 'utf8'), readFileSync(askPage, 'utf8'));
|
|
40
|
+
const manifest = JSON.parse(readFileSync(pageManifestPath(submitted.dir), 'utf8'));
|
|
41
|
+
assert.equal(manifest['schema'], 'crtr.page/v1');
|
|
42
|
+
assert.equal(manifest['document'], 'page.md');
|
|
43
|
+
assert.equal(manifest['placement'], 'inline');
|
|
44
|
+
const source = manifest['source'];
|
|
45
|
+
assert.equal(source.nodeId, 'node-1');
|
|
46
|
+
assert.ok(Date.parse(source.blockedSince) > 0, 'submission stamps blockedSince');
|
|
47
|
+
assert.throws(() => submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'md' }), /already exists/);
|
|
48
|
+
const listed = scanInbox().find((item) => item.id === 'ask');
|
|
49
|
+
assert.equal(listed?.kind, 'page');
|
|
50
|
+
assert.equal(listed?.kind === 'page' ? listed.awaitsResponse : undefined, true);
|
|
51
|
+
assert.deepEqual(listed?.kind === 'page' ? listed.slotKinds : undefined, ['options']);
|
|
52
|
+
});
|
|
53
|
+
test('progress round-trips through validation and rejects malformed saved work', () => {
|
|
54
|
+
const dir = ticketDir('ask');
|
|
55
|
+
assert.equal(readPageProgress(dir), null, 'no progress file reads as null');
|
|
56
|
+
writePageProgress(dir, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
57
|
+
assert.deepEqual(readPageProgress(dir), { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
58
|
+
assert.throws(() => writePageProgress(dir, { rollout: { selectedOptionIds: ['nope'], comments: [] } }), /nope/);
|
|
59
|
+
assert.throws(() => writePageProgress(dir, { unknown: { selectedOptionIds: [], comments: [] } }), /unknown/);
|
|
60
|
+
atomicWriteJson(progressPath(dir), { kind: 'page', responses: { rollout: 'not an object' }, savedAt: new Date().toISOString() });
|
|
61
|
+
assert.throws(() => readPageProgress(dir), /malformed/, 'malformed progress is an error, never silently discarded');
|
|
62
|
+
writePageProgress(dir, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
63
|
+
});
|
|
64
|
+
test('readPageTicket derives pending, resolved and canceled state and carries the document', () => {
|
|
65
|
+
const dir = ticketDir('ask');
|
|
66
|
+
const pending = readPageTicket(dir);
|
|
67
|
+
assert.equal(pending.state, 'pending');
|
|
68
|
+
assert.equal(pending.manifest.title, 'Rollout scope');
|
|
69
|
+
assert.ok(pending.document.includes('Which rollout do you want?'));
|
|
70
|
+
assert.deepEqual(pending.progress, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
|
|
71
|
+
const claim = claimTicket(dir);
|
|
72
|
+
assert.notEqual(claim, null, 'a page ticket is claimable');
|
|
73
|
+
assert.throws(() => completePage(dir, { rollout: { selectedOptionIds: [], comments: [] } }, claim.token), /rollout/, 'single-select requires one selection');
|
|
74
|
+
const completed = completePage(dir, { rollout: { selectedOptionIds: ['full'], comments: [] }, }, claim.token);
|
|
75
|
+
assert.equal(completed.won, true);
|
|
76
|
+
const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
|
|
77
|
+
assert.equal(written['schema'], 'crtr.page-response/v1');
|
|
78
|
+
assert.equal(written['kind'], 'page');
|
|
79
|
+
assert.deepEqual(written['responses'], { rollout: { selectedOptionIds: ['full'], comments: [] } });
|
|
80
|
+
assert.equal(written['summary'], 'rollout: Full');
|
|
81
|
+
assert.equal(readTicketResult(dir)?.kind, 'page', 'the strict decoder accepts the page result');
|
|
82
|
+
assert.equal(existsSync(progressPath(dir)), false, 'completion clears owned work');
|
|
83
|
+
const resolved = readPageTicket(dir);
|
|
84
|
+
assert.equal(resolved.state, 'resolved');
|
|
85
|
+
assert.equal(resolved.progress, null);
|
|
86
|
+
assert.equal(scanInbox().some((item) => item.id === 'ask'), false, 'a resolved page leaves the pending list');
|
|
87
|
+
const canceledDir = submit('canceled', askPage, 'md', '2026-01-03T00:00:00.000Z');
|
|
88
|
+
cancelTicket(canceledDir, { reason: 'no longer needed' });
|
|
89
|
+
assert.equal(readPageTicket(canceledDir).state, 'canceled');
|
|
90
|
+
});
|
|
91
|
+
test('a display page acknowledges with the empty responses map', () => {
|
|
92
|
+
const dir = submit('display', displayPage, 'html', '2026-01-04T00:00:00.000Z');
|
|
93
|
+
const ticket = readPageTicket(dir);
|
|
94
|
+
assert.equal(ticket.manifest.dialect, 'html');
|
|
95
|
+
assert.equal(ticket.manifest.slots.length, 0);
|
|
96
|
+
assert.equal(scanInbox().find((item) => item.id === 'display')?.kind === 'page', true);
|
|
97
|
+
const acknowledged = takeoverAndCompletePage(dir, {}, { host: 'crtrd', pid: process.pid });
|
|
98
|
+
assert.equal(acknowledged.won, true);
|
|
99
|
+
const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
|
|
100
|
+
assert.equal(written['schema'], 'crtr.page-response/v1');
|
|
101
|
+
assert.deepEqual(written['responses'], {});
|
|
102
|
+
assert.equal(written['summary'], '');
|
|
103
|
+
});
|
|
104
|
+
test('scanPageHistory returns every page of one node, oldest first, regardless of terminal file', () => {
|
|
105
|
+
submit('older', askPage, 'md', '2026-01-01T00:00:00.000Z');
|
|
106
|
+
submit('other-node', askPage, 'md', '2026-01-02T00:00:00.000Z', 'node-2');
|
|
107
|
+
const history = scanPageHistory('node-1');
|
|
108
|
+
assert.deepEqual(history.map((entry) => entry.id), ['older', 'canceled', 'display', 'ask']);
|
|
109
|
+
assert.deepEqual(history.map((entry) => entry.state), ['pending', 'canceled', 'resolved', 'resolved']);
|
|
110
|
+
assert.deepEqual(scanPageHistory('node-2').map((entry) => entry.id), ['other-node']);
|
|
111
|
+
assert.deepEqual(scanPageHistory('node-missing'), []);
|
|
112
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { derivePageManifest, parsePageSource } from '../page.js';
|
|
7
|
+
import { isResponseBearingSlot, validatePageResponses, validatePartialPageResponses } from '../page-schema.js';
|
|
8
|
+
function withSource(name, source, run) {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-page-test-'));
|
|
10
|
+
try {
|
|
11
|
+
const path = join(dir, name);
|
|
12
|
+
writeFileSync(path, source);
|
|
13
|
+
run(path);
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const htmlHead = '<!doctype html><html><head><meta name="crtr-page-title" content="Test"></head><body>';
|
|
20
|
+
const htmlTail = '</body></html>';
|
|
21
|
+
const optionsScript = '<script type="application/json">{"mode":"single","options":[{"id":"yes","label":"Yes"}]}</script>';
|
|
22
|
+
function deriveHtml(body, head = htmlHead) {
|
|
23
|
+
let manifest;
|
|
24
|
+
withSource('input.html', `${head}${body}${htmlTail}`, (path) => { manifest = derivePageManifest(path, 'html'); });
|
|
25
|
+
return manifest;
|
|
26
|
+
}
|
|
27
|
+
function expectHtmlError(body, pattern, head = htmlHead) {
|
|
28
|
+
withSource('input.html', `${head}${body}${htmlTail}`, (path) => assert.throws(() => derivePageManifest(path, 'html'), pattern));
|
|
29
|
+
}
|
|
30
|
+
test('md dialect derives a normalized manifest and preserves top-level slot scanning', () => {
|
|
31
|
+
const source = `---
|
|
32
|
+
title: Rollout
|
|
33
|
+
subtitle: Choose carefully
|
|
34
|
+
placement: inline
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
Before.
|
|
38
|
+
|
|
39
|
+
\`\`\`\`markdown
|
|
40
|
+
\`\`\`slot:text
|
|
41
|
+
{"id":"nested","initialText":"not a slot"}
|
|
42
|
+
\`\`\`
|
|
43
|
+
\`\`\`\`
|
|
44
|
+
|
|
45
|
+
\`\`\`slot:table
|
|
46
|
+
{"columns":[{"id":"day","label":"Day"}],"rows":[{"id":"r1","cells":{"day":"Mon"}}]}
|
|
47
|
+
\`\`\`
|
|
48
|
+
|
|
49
|
+
\`\`\`slot:options
|
|
50
|
+
{"id":"rollout","mode":"single","options":[{"id":"yes","label":"Yes"}]}
|
|
51
|
+
\`\`\`
|
|
52
|
+
`;
|
|
53
|
+
withSource('input.md', source, (path) => {
|
|
54
|
+
const manifest = derivePageManifest(path, 'md');
|
|
55
|
+
// A display-only table's schema defaults must survive the manifest→source round trip.
|
|
56
|
+
assert.deepEqual(parsePageSource(source, manifest).at(-1), { kind: 'slot', slotIndex: 1 });
|
|
57
|
+
assert.equal(manifest.title, 'Rollout');
|
|
58
|
+
assert.equal(manifest.subtitle, 'Choose carefully');
|
|
59
|
+
assert.equal(manifest.placement, 'inline');
|
|
60
|
+
assert.equal(manifest.document, 'page.md');
|
|
61
|
+
assert.equal(manifest.steps, 1);
|
|
62
|
+
assert.deepEqual(manifest.slots.map(({ id, kind, step }) => ({ id, kind, step })), [{ id: undefined, kind: 'table', step: 0 }, { id: 'rollout', kind: 'options', step: 0 }]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
test('html dialect derives document-order slots and wrapper steps', () => {
|
|
66
|
+
const body = `<crtr-pages><crtr-page><section><crtr-options id="choice">${optionsScript}</crtr-options></section></crtr-page><crtr-page><crtr-chart><script type="application/json">{"kind":"bar","x":"day","y":[{"field":"count"}]}</script></crtr-chart></crtr-page></crtr-pages>`;
|
|
67
|
+
const manifest = deriveHtml(body, '<!doctype html><html placement="inline"><head><meta name="crtr-page-title" content="HTML"><meta name="crtr-page-subtitle" content="Two steps"><meta name="crtr-page-placement" content="inline"></head><body>');
|
|
68
|
+
assert.equal(manifest.title, 'HTML');
|
|
69
|
+
assert.equal(manifest.placement, 'inline');
|
|
70
|
+
assert.equal(manifest.steps, 2);
|
|
71
|
+
assert.deepEqual(manifest.slots.map(({ kind, step }) => ({ kind, step })), [{ kind: 'options', step: 0 }, { kind: 'chart', step: 1 }]);
|
|
72
|
+
});
|
|
73
|
+
test('html wrapper rejects multiple crtr-pages', () => expectHtmlError('<crtr-pages><crtr-page></crtr-page></crtr-pages><crtr-pages><crtr-page></crtr-page></crtr-pages>', /more than one <crtr-pages>/));
|
|
74
|
+
test('html wrapper rejects crtr-page outside crtr-pages', () => expectHtmlError('<crtr-page></crtr-page>', /<crtr-page> outside <crtr-pages>/));
|
|
75
|
+
test('html wrapper rejects empty crtr-pages', () => expectHtmlError('<crtr-pages></crtr-pages>', /empty <crtr-pages>/));
|
|
76
|
+
test('html wrapper rejects a response-bearing element outside a step with the fix', () => expectHtmlError(`<crtr-options id="choice">${optionsScript}</crtr-options>`, /response-bearing element <crtr-options>[\s\S]*<crtr-pages><crtr-page>…<\/crtr-page><\/crtr-pages>/));
|
|
77
|
+
test('html wrapper rejects nested crtr-pages', () => expectHtmlError('<crtr-pages><crtr-page><crtr-pages><crtr-page></crtr-page></crtr-pages></crtr-page></crtr-pages>', /nested <crtr-pages>/));
|
|
78
|
+
test('md front matter names unknown, empty-title, and placement errors', () => {
|
|
79
|
+
for (const [source, pattern] of [
|
|
80
|
+
['---\ntitle: T\nother: no\n---\n', /unknown page front matter key "other"/],
|
|
81
|
+
['---\ntitle: \n---\n', /title is required and must be nonempty/],
|
|
82
|
+
['---\ntitle: T\nplacement: sidebar\n---\n', /placement must be exactly inline or panel/],
|
|
83
|
+
])
|
|
84
|
+
withSource('input.md', source, (path) => assert.throws(() => derivePageManifest(path, 'md'), pattern));
|
|
85
|
+
});
|
|
86
|
+
test('html metadata enforces cardinality, known names, and placement content', () => {
|
|
87
|
+
const duplicateTitle = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-title" content="B"></head><body>';
|
|
88
|
+
const duplicateSubtitle = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-subtitle" content="A"><meta name="crtr-page-subtitle" content="B"></head><body>';
|
|
89
|
+
const unknown = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-extra" content="x"></head><body>';
|
|
90
|
+
const badPlacement = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-placement" content="drawer"></head><body>';
|
|
91
|
+
expectHtmlError('', /Expected exactly one <meta name="crtr-page-title">, found 2/, duplicateTitle);
|
|
92
|
+
expectHtmlError('', /Expected at most one <meta name="crtr-page-subtitle">, found 2/, duplicateSubtitle);
|
|
93
|
+
expectHtmlError('', /unknown page metadata element <meta name="crtr-page-extra">/, unknown);
|
|
94
|
+
expectHtmlError('', /content must be exactly inline or panel/, badPlacement);
|
|
95
|
+
});
|
|
96
|
+
test('the response-bearing predicate handles table selection and catalog edges', () => {
|
|
97
|
+
const slot = (kind, config, unvalidated) => ({ kind, step: 0, config, ...(unvalidated ? { unvalidated } : {}) });
|
|
98
|
+
assert.equal(isResponseBearingSlot(slot('table', { columns: [{ id: 'c', label: 'C' }], rowSelect: 'none', columnSelect: 'none' })), false);
|
|
99
|
+
assert.equal(isResponseBearingSlot(slot('table', { columns: [{ id: 'c', label: 'C' }], rowSelect: 'single', columnSelect: 'none' })), true);
|
|
100
|
+
assert.equal(isResponseBearingSlot(slot('chart', { kind: 'bar', x: 'x', y: [{ field: 'y' }] })), false);
|
|
101
|
+
assert.equal(isResponseBearingSlot(slot('product-kind', {}, true)), true);
|
|
102
|
+
});
|
|
103
|
+
test('final and partial response validation share slot-addressed rules', () => {
|
|
104
|
+
const manifest = deriveHtml(`<crtr-pages><crtr-page><crtr-options id="choice">${optionsScript}</crtr-options><crtr-text id="note"><script type="application/json">{"initialText":""}</script></crtr-text></crtr-page></crtr-pages>`);
|
|
105
|
+
assert.deepEqual(validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] } }), { choice: { selectedOptionIds: ['yes'], comments: [] } });
|
|
106
|
+
assert.throws(() => validatePageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] } }), /missing response-bearing slot id "note"/);
|
|
107
|
+
assert.deepEqual(validatePageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] }, note: { text: '', edited: false, comments: [] } }), { choice: { selectedOptionIds: ['yes'], comments: [] }, note: { text: '', edited: false, comments: [] } });
|
|
108
|
+
assert.throws(() => validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['maybe'], comments: [] } }), /unknown option id "maybe"/);
|
|
109
|
+
assert.throws(() => validatePartialPageResponses(manifest, { extra: {} }), /unknown or non-response-bearing slot id "extra"/);
|
|
110
|
+
});
|
|
111
|
+
test('PageSourceProjection associates leading, interstitial, and trailing prose', () => {
|
|
112
|
+
const source = `---
|
|
113
|
+
title: Projection
|
|
114
|
+
---
|
|
115
|
+
Leading prose.
|
|
116
|
+
|
|
117
|
+
\`\`\`slot:text
|
|
118
|
+
{"id":"first","initialText":""}
|
|
119
|
+
\`\`\`
|
|
120
|
+
|
|
121
|
+
Interstitial prose.
|
|
122
|
+
|
|
123
|
+
\`\`\`slot:text
|
|
124
|
+
{"id":"second","initialText":""}
|
|
125
|
+
\`\`\`
|
|
126
|
+
|
|
127
|
+
Trailing prose.
|
|
128
|
+
`;
|
|
129
|
+
withSource('input.md', source, (path) => {
|
|
130
|
+
const manifest = derivePageManifest(path, 'md');
|
|
131
|
+
assert.deepEqual(parsePageSource(source, manifest), [
|
|
132
|
+
{ kind: 'prose', markdown: 'Leading prose.' },
|
|
133
|
+
{ kind: 'slot', slotIndex: 0 },
|
|
134
|
+
{ kind: 'prose', markdown: 'Interstitial prose.' },
|
|
135
|
+
{ kind: 'slot', slotIndex: 1 },
|
|
136
|
+
{ kind: 'prose', markdown: 'Trailing prose.' },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
});
|