@crouton-kit/crouter 0.3.70 → 0.3.79
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 +1 -1
- package/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +841 -792
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +11 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +127 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +9 -2
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +49 -1
- package/dist/commands/sys/setup-core.js +161 -4
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +943 -169
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +230 -41
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +48 -0
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +257 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +86 -52
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +164 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +78 -20
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +75 -47
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/session-list-cache.d.ts +33 -0
- package/dist/core/runtime/session-list-cache.js +338 -0
- package/dist/core/runtime/spawn.js +52 -45
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +217 -110
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +73 -46
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/types.d.ts +9 -26
- package/dist/types.js +1 -28
- package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +8 -4
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// Contract-seam tests for the external command-plugin seam (Phase 1).
|
|
2
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/command-plugins.test.ts
|
|
3
|
+
//
|
|
4
|
+
// All tests run against an ISOLATED home (HOME → a fresh temp dir, no ambient
|
|
5
|
+
// user plugins) and pass an explicit no-`.crouter` startDir, so discovery is
|
|
6
|
+
// deterministic and cannot pick up the developer's real machine state.
|
|
7
|
+
//
|
|
8
|
+
// Covers the spec's Tests items:
|
|
9
|
+
// 1. Registry composition — a fixture top-level branch + leaf composes into
|
|
10
|
+
// real BranchDef/LeafDef whose root/branch/leaf help carries the plugin's
|
|
11
|
+
// representations, schema, effects, and stable order.
|
|
12
|
+
// 2. Core collision — a fixture claiming a SUBTREE_NAMES name is skipped +
|
|
13
|
+
// recorded as a command_collision issue.
|
|
14
|
+
// 3. Scope/shadowing — a project copy shadows a user copy of the same plugin
|
|
15
|
+
// name; a disabled project copy suppresses the user copy.
|
|
16
|
+
// 4. Protocol integration (table-driven, one fixture executable) — request
|
|
17
|
+
// envelope contents, rendered success, --json mirror, stderr separation,
|
|
18
|
+
// recoverable error propagation, and every malformed case.
|
|
19
|
+
// 5. Lifecycle without restart — install/disable/edit/remove all take effect
|
|
20
|
+
// on the next discovery call (per-invocation, no cache).
|
|
21
|
+
// 6/7. Typed issues for path/exec-bit/schema/unknown-key/parent violations
|
|
22
|
+
// without executing the fixture binary.
|
|
23
|
+
import { test, describe, before, after, beforeEach } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from 'node:fs';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { join, dirname } from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../command-plugins/discovery.js';
|
|
32
|
+
import { composeExternalSubtrees } from '../command-plugins/compose.js';
|
|
33
|
+
import { defineRoot } from '../command.js';
|
|
34
|
+
import { renderRoot, renderBranch, renderLeafArgv } from '../help.js';
|
|
35
|
+
import { renderResult } from '../render.js';
|
|
36
|
+
import { CrtrError } from '../errors.js';
|
|
37
|
+
import { resetScopeCache } from '../scope.js';
|
|
38
|
+
import { listInstalledPluginsInRoot } from '../resolver.js';
|
|
39
|
+
// The set of reserved core names discovery must fail closed against. We don't
|
|
40
|
+
// import the real SUBTREE_NAMES (build-root.ts pulls the whole tree); a small
|
|
41
|
+
// explicit set is enough for the collision contract and keeps the test cheap.
|
|
42
|
+
const RESERVED = new Set(['push', 'node', 'canvas', 'sys', 'memory']);
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Fixture executable: reads one JSON request on stdin, writes one envelope.
|
|
45
|
+
// Behavior is selected by req.input.mode so one binary drives the whole table.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
const FIXTURE_EXEC = `#!/usr/bin/env node
|
|
48
|
+
const fs = require('fs');
|
|
49
|
+
let data = '';
|
|
50
|
+
process.stdin.on('data', (c) => (data += c));
|
|
51
|
+
process.stdin.on('end', () => {
|
|
52
|
+
const req = JSON.parse(data);
|
|
53
|
+
const log = process.env.FIXTURE_REQUEST_LOG;
|
|
54
|
+
if (log) fs.writeFileSync(log, JSON.stringify(req));
|
|
55
|
+
const mode = (req.input && req.input.mode) || 'ok';
|
|
56
|
+
const env = (o, code) => { process.stdout.write(JSON.stringify(o)); process.exit(code); };
|
|
57
|
+
if (mode === 'invalid-json') { process.stdout.write('this is not json'); process.exit(0); }
|
|
58
|
+
if (mode === 'extra-stdout') { process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { app_id: 'x' } }) + '\\nEXTRA JUNK'); process.exit(0); }
|
|
59
|
+
if (mode === 'oversize') { process.stdout.write('x'.repeat(11 * 1024 * 1024)); process.exit(0); }
|
|
60
|
+
if (mode === 'signal') { process.kill(process.pid, 'SIGKILL'); return; }
|
|
61
|
+
if (mode === 'error') return env({ protocolVersion: 1, ok: false, error: { code: 'authentication_required', message: 'No home session is available.', field: 'session', next: 'Log in, then retry.' } }, 1);
|
|
62
|
+
if (mode === 'reserved-code') return env({ protocolVersion: 1, ok: false, error: { code: 'internal', message: 'boom' } }, 1);
|
|
63
|
+
if (mode === 'missing-field') return env({ protocolVersion: 1, ok: true, result: { status: 'running' } }, 0);
|
|
64
|
+
if (mode === 'exit-mismatch') return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 3);
|
|
65
|
+
if (mode === 'diag') { process.stderr.write('a diagnostic line on stderr\\n'); return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 0); }
|
|
66
|
+
return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 0);
|
|
67
|
+
});
|
|
68
|
+
`;
|
|
69
|
+
function commandsJson(topName = 'app') {
|
|
70
|
+
return {
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
executable: 'bin/cmd.js',
|
|
73
|
+
mounts: [
|
|
74
|
+
{
|
|
75
|
+
parent: [],
|
|
76
|
+
node: {
|
|
77
|
+
kind: 'branch',
|
|
78
|
+
name: topName,
|
|
79
|
+
description: 'application lifecycle and inspection',
|
|
80
|
+
whenToUse: 'you need to create, deploy, inspect, or remove an application',
|
|
81
|
+
rootEntry: {
|
|
82
|
+
concept: 'applications hosted by a crouter home',
|
|
83
|
+
description: 'application lifecycle and inspection',
|
|
84
|
+
whenToUse: 'you need to create, deploy, inspect, or remove an application',
|
|
85
|
+
},
|
|
86
|
+
summary: 'application lifecycle and inspection',
|
|
87
|
+
model: 'Applications are product resources owned by the selected home.',
|
|
88
|
+
children: [
|
|
89
|
+
{
|
|
90
|
+
kind: 'leaf',
|
|
91
|
+
name: 'show',
|
|
92
|
+
description: 'show one application',
|
|
93
|
+
whenToUse: 'inspect a single application by id',
|
|
94
|
+
tier: 'important',
|
|
95
|
+
summary: 'show one application details',
|
|
96
|
+
params: [
|
|
97
|
+
{ kind: 'positional', name: 'app-id', required: true, constraint: 'the application id' },
|
|
98
|
+
{ kind: 'flag', name: 'mode', type: 'string', required: false, constraint: 'fixture behavior selector' },
|
|
99
|
+
],
|
|
100
|
+
output: [
|
|
101
|
+
{ name: 'app_id', type: 'string', required: true, constraint: 'the echoed application id' },
|
|
102
|
+
{ name: 'status', type: 'string', required: false, constraint: 'current application status' },
|
|
103
|
+
],
|
|
104
|
+
outputKind: 'object',
|
|
105
|
+
effects: ['None. Read-only.'],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Write a plugin into `scopeRoot/plugins/<name>/` with a command manifest +
|
|
114
|
+
* executable. `manifest` is the commands.json object (default fixture). */
|
|
115
|
+
function installPlugin(scopeRoot, name, opts = {}) {
|
|
116
|
+
const root = join(scopeRoot, 'plugins', name);
|
|
117
|
+
mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
|
|
118
|
+
mkdirSync(join(root, 'bin'), { recursive: true });
|
|
119
|
+
const pointer = opts.commandsPointer ?? 'commands.json';
|
|
120
|
+
writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name, version: '0.1.0', description: 'fixture', commands: pointer }));
|
|
121
|
+
writeFileSync(join(root, 'commands.json'), JSON.stringify(opts.manifest ?? commandsJson()));
|
|
122
|
+
const execPath = join(root, 'bin', 'cmd.js');
|
|
123
|
+
writeFileSync(execPath, opts.exec ?? FIXTURE_EXEC);
|
|
124
|
+
chmodSync(execPath, opts.execBit === false ? 0o644 : 0o755);
|
|
125
|
+
if (opts.enabled === false) {
|
|
126
|
+
writeFileSync(join(scopeRoot, 'config.json'), JSON.stringify({ plugins: { [name]: { enabled: false } } }));
|
|
127
|
+
}
|
|
128
|
+
return root;
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Isolated home + workspace
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
let home; // becomes HOME → user scope root = $HOME/.crouter
|
|
134
|
+
let userRoot;
|
|
135
|
+
let projectDir; // a workspace with its own .crouter (project scope)
|
|
136
|
+
let projectRoot;
|
|
137
|
+
let emptyStart; // a startDir with NO .crouter anywhere above it
|
|
138
|
+
let realHome;
|
|
139
|
+
let realProfile;
|
|
140
|
+
before(() => {
|
|
141
|
+
realHome = process.env['HOME'];
|
|
142
|
+
realProfile = process.env['CRTR_PROFILE_ID'];
|
|
143
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
144
|
+
});
|
|
145
|
+
after(() => {
|
|
146
|
+
if (realHome !== undefined)
|
|
147
|
+
process.env['HOME'] = realHome;
|
|
148
|
+
else
|
|
149
|
+
delete process.env['HOME'];
|
|
150
|
+
if (realProfile !== undefined)
|
|
151
|
+
process.env['CRTR_PROFILE_ID'] = realProfile;
|
|
152
|
+
});
|
|
153
|
+
// Every temp dir any test mints, so cleanup removes them ALL (an
|
|
154
|
+
// after(...[home, ...]) would only see the LAST test's set and leak the rest).
|
|
155
|
+
const tempDirs = [];
|
|
156
|
+
function mintDir(prefix) {
|
|
157
|
+
const d = mkdtempSync(join(tmpdir(), prefix));
|
|
158
|
+
tempDirs.push(d);
|
|
159
|
+
return d;
|
|
160
|
+
}
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
home = mintDir('crtr-cmdplugin-home-');
|
|
163
|
+
process.env['HOME'] = home;
|
|
164
|
+
userRoot = join(home, '.crouter');
|
|
165
|
+
mkdirSync(join(userRoot, 'plugins'), { recursive: true });
|
|
166
|
+
projectDir = mintDir('crtr-cmdplugin-proj-');
|
|
167
|
+
projectRoot = join(projectDir, '.crouter');
|
|
168
|
+
mkdirSync(join(projectRoot, 'plugins'), { recursive: true });
|
|
169
|
+
emptyStart = mintDir('crtr-cmdplugin-empty-');
|
|
170
|
+
resetScopeCache();
|
|
171
|
+
});
|
|
172
|
+
after(() => {
|
|
173
|
+
for (const d of tempDirs) {
|
|
174
|
+
try {
|
|
175
|
+
rmSync(d, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
catch { /* ignore */ }
|
|
178
|
+
}
|
|
179
|
+
resetScopeCache();
|
|
180
|
+
});
|
|
181
|
+
function leafOf(branch, name) {
|
|
182
|
+
const c = branch.children.find((x) => x.name === name);
|
|
183
|
+
assert.ok(c !== undefined && c.kind === 'leaf', `expected leaf ${name}`);
|
|
184
|
+
return c;
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// 1. Registry composition
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
describe('registry composition', () => {
|
|
190
|
+
test('a fixture top-level branch composes with plugin-owned help, schema, effects, stable order', () => {
|
|
191
|
+
installPlugin(userRoot, 'crouter-infra');
|
|
192
|
+
resetScopeCache();
|
|
193
|
+
const snapshot = discoverCommandContributions(RESERVED, emptyStart);
|
|
194
|
+
// user scope is discovered from any startDir (it's not a project ancestor).
|
|
195
|
+
assert.equal(snapshot.issues.length, 0, JSON.stringify(snapshot.issues));
|
|
196
|
+
const external = composeExternalSubtrees(snapshot);
|
|
197
|
+
assert.equal(external.length, 1);
|
|
198
|
+
const app = external[0];
|
|
199
|
+
assert.equal(app.kind, 'branch');
|
|
200
|
+
assert.equal(app.name, 'app');
|
|
201
|
+
// Root help carries the plugin-owned rootEntry (mapped description→desc,
|
|
202
|
+
// whenToUse→useWhen), no core file knows what an app is.
|
|
203
|
+
const root = defineRoot({ tagline: 'test', globals: [], subtrees: external });
|
|
204
|
+
const rootOut = renderRoot(root.help);
|
|
205
|
+
assert.match(rootOut, /<command name="app">/);
|
|
206
|
+
assert.match(rootOut, /applications hosted by a crouter home/);
|
|
207
|
+
assert.match(rootOut, /use when you need to create, deploy, inspect, or remove an application/);
|
|
208
|
+
// important child promoted into root with its shortform desc.
|
|
209
|
+
assert.match(rootOut, /app show {2,}show one application/);
|
|
210
|
+
// Branch help lists the leaf via its own description/whenToUse.
|
|
211
|
+
const branchOut = renderBranch(app.help);
|
|
212
|
+
assert.match(branchOut, /<command name="app" description="application lifecycle and inspection">/);
|
|
213
|
+
assert.match(branchOut, /Applications are product resources owned by the selected home\./);
|
|
214
|
+
assert.match(branchOut, /name="show"[^>]*whenToUse="inspect a single application by id"/);
|
|
215
|
+
// Leaf help carries the full input/output schema + effects.
|
|
216
|
+
const leaf = leafOf(app, 'show');
|
|
217
|
+
const leafOut = renderLeafArgv(leaf.help);
|
|
218
|
+
assert.match(leafOut, /APP-ID/);
|
|
219
|
+
assert.match(leafOut, /--mode MODE/);
|
|
220
|
+
assert.match(leafOut, /app_id {2,}string\. the echoed application id/);
|
|
221
|
+
assert.match(leafOut, /None\. Read-only\./);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// 2. Core collision
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
describe('core collision', () => {
|
|
228
|
+
test('a fixture claiming a core top-level name is skipped + recorded', () => {
|
|
229
|
+
installPlugin(userRoot, 'rogue', { manifest: commandsJson('push') });
|
|
230
|
+
resetScopeCache();
|
|
231
|
+
const snapshot = discoverCommandContributions(RESERVED, emptyStart);
|
|
232
|
+
assert.equal(composeExternalSubtrees(snapshot).length, 0, 'the colliding branch must not enter the tree');
|
|
233
|
+
const collision = snapshot.issues.find((i) => i.code === 'command_collision');
|
|
234
|
+
assert.ok(collision, 'a command_collision issue must be recorded for doctor/show');
|
|
235
|
+
assert.equal(collision.received, 'push');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// 3. Scope / shadowing
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
describe('scope shadowing', () => {
|
|
242
|
+
test('a project copy shadows a user copy of the same plugin name', () => {
|
|
243
|
+
installPlugin(userRoot, 'crouter-infra');
|
|
244
|
+
installPlugin(projectRoot, 'crouter-infra');
|
|
245
|
+
resetScopeCache();
|
|
246
|
+
const effective = effectiveCommandPlugins(projectDir);
|
|
247
|
+
assert.equal(effective.length, 1);
|
|
248
|
+
assert.equal(effective[0].scope, 'project');
|
|
249
|
+
assert.equal(effective[0].root, join(projectRoot, 'plugins', 'crouter-infra'));
|
|
250
|
+
});
|
|
251
|
+
test('a disabled project copy suppresses the user copy (no resurfacing)', () => {
|
|
252
|
+
installPlugin(userRoot, 'crouter-infra');
|
|
253
|
+
installPlugin(projectRoot, 'crouter-infra', { enabled: false });
|
|
254
|
+
resetScopeCache();
|
|
255
|
+
// sanity: the project copy really is disabled at that root.
|
|
256
|
+
const projPlugins = listInstalledPluginsInRoot('project', projectRoot);
|
|
257
|
+
assert.equal(projPlugins.find((p) => p.name === 'crouter-infra').enabled, false);
|
|
258
|
+
const effective = effectiveCommandPlugins(projectDir);
|
|
259
|
+
assert.equal(effective.length, 0, 'the disabled higher-precedence copy must suppress the user copy');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// 4. Protocol integration (table-driven)
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
describe('protocol integration', () => {
|
|
266
|
+
function showLeaf() {
|
|
267
|
+
installPlugin(userRoot, 'crouter-infra');
|
|
268
|
+
resetScopeCache();
|
|
269
|
+
const snapshot = discoverCommandContributions(RESERVED, emptyStart);
|
|
270
|
+
const app = composeExternalSubtrees(snapshot)[0];
|
|
271
|
+
return leafOf(app, 'show');
|
|
272
|
+
}
|
|
273
|
+
test('request envelope carries command path, camelCase input, cwd, plugin context', async () => {
|
|
274
|
+
const leaf = showLeaf();
|
|
275
|
+
const log = join(emptyStart, 'req.json');
|
|
276
|
+
process.env['FIXTURE_REQUEST_LOG'] = log;
|
|
277
|
+
try {
|
|
278
|
+
await leaf.run({ appId: 'app_123', mode: 'ok' });
|
|
279
|
+
const req = JSON.parse(readFileSync(log, 'utf8'));
|
|
280
|
+
assert.equal(req.protocolVersion, 1);
|
|
281
|
+
assert.deepEqual(req.command, ['app', 'show']);
|
|
282
|
+
assert.equal(req.input.appId, 'app_123');
|
|
283
|
+
assert.equal(req.context.cwd, process.cwd());
|
|
284
|
+
assert.equal(req.context.plugin.name, 'crouter-infra');
|
|
285
|
+
assert.equal(req.context.plugin.scope, 'user');
|
|
286
|
+
assert.equal(req.context.plugin.version, '0.1.0');
|
|
287
|
+
assert.equal(req.context.plugin.root, join(userRoot, 'plugins', 'crouter-infra'));
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
delete process.env['FIXTURE_REQUEST_LOG'];
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
test('ok:true → result validated + rendered; --json mirrors the same object', async () => {
|
|
294
|
+
const leaf = showLeaf();
|
|
295
|
+
const result = await leaf.run({ appId: 'app_123', mode: 'ok' });
|
|
296
|
+
assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
|
|
297
|
+
// Rendered through the existing generic renderer (no custom renderer).
|
|
298
|
+
const rendered = renderResult(result, leaf.help);
|
|
299
|
+
assert.match(rendered, /app_id/);
|
|
300
|
+
assert.match(rendered, /app_123/);
|
|
301
|
+
});
|
|
302
|
+
test('valid envelope + wrong exit code is honored (soft diagnostic, not a discard)', async () => {
|
|
303
|
+
const leaf = showLeaf();
|
|
304
|
+
const result = await leaf.run({ appId: 'app_123', mode: 'exit-mismatch' });
|
|
305
|
+
assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
|
|
306
|
+
});
|
|
307
|
+
test('stderr diagnostics do not pollute the stdout envelope', async () => {
|
|
308
|
+
const leaf = showLeaf();
|
|
309
|
+
const result = await leaf.run({ appId: 'app_123', mode: 'diag' });
|
|
310
|
+
assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
|
|
311
|
+
});
|
|
312
|
+
test('ok:false → structured CrtrError with the plugin code', async () => {
|
|
313
|
+
const leaf = showLeaf();
|
|
314
|
+
await assert.rejects(() => leaf.run({ appId: 'app_123', mode: 'error' }), (e) => e instanceof CrtrError && e.code === 'authentication_required' && (e.details?.['next'] === 'Log in, then retry.'));
|
|
315
|
+
});
|
|
316
|
+
const malformed = [
|
|
317
|
+
['invalid JSON', 'invalid-json'],
|
|
318
|
+
['extra stdout after the envelope', 'extra-stdout'],
|
|
319
|
+
['oversize stdout (>10 MiB)', 'oversize'],
|
|
320
|
+
['signal termination', 'signal'],
|
|
321
|
+
['missing declared output field', 'missing-field'],
|
|
322
|
+
['reserved error code', 'reserved-code'],
|
|
323
|
+
];
|
|
324
|
+
for (const [desc, mode] of malformed) {
|
|
325
|
+
test(`malformed: ${desc} → plugin_protocol_error`, async () => {
|
|
326
|
+
const leaf = showLeaf();
|
|
327
|
+
await assert.rejects(() => leaf.run({ appId: 'app_123', mode }), (e) => e instanceof CrtrError && e.code === 'plugin_protocol_error');
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// 4b. True end-to-end CLI invocation (argv → parseArgv → adapter → renderer)
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
describe('end-to-end CLI invocation', () => {
|
|
335
|
+
// dist/cli.js is up 4 from src/core/__tests__/. Requires a prior `npm run
|
|
336
|
+
// build` (the harness/CI builds before tests). Drives the WHOLE seam: the
|
|
337
|
+
// declared positional `app-id` must arrive as `appId` in the request, and
|
|
338
|
+
// the rendered + --json outputs come through the real dispatcher.
|
|
339
|
+
const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'dist', 'cli.js');
|
|
340
|
+
const runCrtr = (args) => {
|
|
341
|
+
installPlugin(userRoot, 'crouter-infra');
|
|
342
|
+
resetScopeCache();
|
|
343
|
+
return execFileSync(process.execPath, [cli, ...args], {
|
|
344
|
+
cwd: emptyStart,
|
|
345
|
+
env: { ...process.env, HOME: home, CRTR_NO_BOOTSTRAP: '1', CRTR_FRONT_DOOR: '' },
|
|
346
|
+
encoding: 'utf8',
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
test('crtr app show <id> renders the plugin result end to end', { timeout: 40000 }, () => {
|
|
350
|
+
const out = runCrtr(['app', 'show', 'app_123']);
|
|
351
|
+
assert.match(out, /app_123/);
|
|
352
|
+
assert.match(out, /running/);
|
|
353
|
+
});
|
|
354
|
+
test('--json mirrors the same result object', { timeout: 40000 }, () => {
|
|
355
|
+
const out = runCrtr(['app', 'show', 'app_123', '--json']);
|
|
356
|
+
const obj = JSON.parse(out);
|
|
357
|
+
assert.equal(obj.app_id, 'app_123');
|
|
358
|
+
assert.equal(obj.status, 'running');
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// 5. Lifecycle without restart (per-invocation discovery)
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
describe('lifecycle without restart', () => {
|
|
365
|
+
test('install → appears; disable → gone; edit → visible; remove → gone', () => {
|
|
366
|
+
const discover = () => {
|
|
367
|
+
resetScopeCache();
|
|
368
|
+
return composeExternalSubtrees(discoverCommandContributions(RESERVED, emptyStart));
|
|
369
|
+
};
|
|
370
|
+
// install
|
|
371
|
+
installPlugin(userRoot, 'crouter-infra');
|
|
372
|
+
assert.equal(discover().length, 1);
|
|
373
|
+
// disable (config edit only — no restart)
|
|
374
|
+
writeFileSync(join(userRoot, 'config.json'), JSON.stringify({ plugins: { 'crouter-infra': { enabled: false } } }));
|
|
375
|
+
assert.equal(discover().length, 0);
|
|
376
|
+
// re-enable + edit the declaration (rename the top-level command)
|
|
377
|
+
writeFileSync(join(userRoot, 'config.json'), JSON.stringify({ plugins: { 'crouter-infra': { enabled: true } } }));
|
|
378
|
+
writeFileSync(join(userRoot, 'plugins', 'crouter-infra', 'commands.json'), JSON.stringify(commandsJson('deploy')));
|
|
379
|
+
const afterEdit = discover();
|
|
380
|
+
assert.equal(afterEdit.length, 1);
|
|
381
|
+
assert.equal(afterEdit[0].name, 'deploy');
|
|
382
|
+
// remove
|
|
383
|
+
rmSync(join(userRoot, 'plugins', 'crouter-infra'), { recursive: true, force: true });
|
|
384
|
+
assert.equal(discover().length, 0);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// 6/7. Typed issues without executing the binary
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
describe('typed validation issues', () => {
|
|
391
|
+
function validate(manifest, extra = {}) {
|
|
392
|
+
installPlugin(userRoot, 'p', { manifest, ...extra });
|
|
393
|
+
resetScopeCache();
|
|
394
|
+
const plugins = listInstalledPluginsInRoot('user', userRoot);
|
|
395
|
+
const p = plugins.find((x) => x.name === 'p');
|
|
396
|
+
return validatePluginCommands(p, RESERVED);
|
|
397
|
+
}
|
|
398
|
+
test('unknown schemaVersion → command_schema_version, no contribution', () => {
|
|
399
|
+
const m = commandsJson();
|
|
400
|
+
m['schemaVersion'] = 2;
|
|
401
|
+
const v = validate(m);
|
|
402
|
+
assert.equal(v.contributions.length, 0);
|
|
403
|
+
assert.ok(v.issues.some((i) => i.code === 'command_schema_version'));
|
|
404
|
+
});
|
|
405
|
+
test('unknown top-level key → command_manifest_invalid', () => {
|
|
406
|
+
const m = commandsJson();
|
|
407
|
+
m['requires'] = { crtr: '>=1' };
|
|
408
|
+
const v = validate(m);
|
|
409
|
+
assert.ok(v.issues.some((i) => i.code === 'command_manifest_invalid'));
|
|
410
|
+
});
|
|
411
|
+
test('non-empty parent → command_parent_invalid', () => {
|
|
412
|
+
const m = commandsJson();
|
|
413
|
+
m.mounts[0].parent = ['sys'];
|
|
414
|
+
const v = validate(m);
|
|
415
|
+
assert.ok(v.issues.some((i) => i.code === 'command_parent_invalid'));
|
|
416
|
+
assert.equal(v.contributions.length, 0);
|
|
417
|
+
});
|
|
418
|
+
test('non-executable file → command_not_executable', () => {
|
|
419
|
+
const v = validate(commandsJson(), { execBit: false });
|
|
420
|
+
assert.ok(v.issues.some((i) => i.code === 'command_not_executable'));
|
|
421
|
+
});
|
|
422
|
+
test('executable escaping the plugin root → command_path_unsafe', () => {
|
|
423
|
+
const m = commandsJson();
|
|
424
|
+
m['executable'] = '../../../../bin/sh';
|
|
425
|
+
const v = validate(m);
|
|
426
|
+
assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
|
|
427
|
+
});
|
|
428
|
+
test('a top-level leaf (no rootEntry) → command_node_invalid', () => {
|
|
429
|
+
const m = {
|
|
430
|
+
schemaVersion: 1,
|
|
431
|
+
executable: 'bin/cmd.js',
|
|
432
|
+
mounts: [{ parent: [], node: { kind: 'leaf', name: 'x', description: 'd', whenToUse: 'w', summary: 's', params: [], output: [], outputKind: 'object', effects: ['None. Read-only.'] } }],
|
|
433
|
+
};
|
|
434
|
+
const v = validate(m);
|
|
435
|
+
assert.ok(v.issues.some((i) => i.code === 'command_node_invalid'));
|
|
436
|
+
assert.equal(v.contributions.length, 0);
|
|
437
|
+
});
|
|
438
|
+
test('a valid manifest yields a contribution and zero issues', () => {
|
|
439
|
+
const v = validate(commandsJson());
|
|
440
|
+
assert.equal(v.issues.length, 0);
|
|
441
|
+
assert.equal(v.contributions.length, 1);
|
|
442
|
+
assert.equal(v.contributions[0].node.name, 'app');
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -28,3 +28,18 @@ test('HTTP 401/403 objects still classify as auth (unchanged path)', () => {
|
|
|
28
28
|
assert.equal(classify('pi→provider', { status: 401, statusText: 'Unauthorized' }).kind, 'auth');
|
|
29
29
|
assert.equal(classify('pi→provider', { status: 403 }).kind, 'auth');
|
|
30
30
|
});
|
|
31
|
+
// Stale ⚠ triangles — undici mid-stream disconnect text (e.g. a laptop sleep/
|
|
32
|
+
// wake episode) must classify as a transient connection blip (disposition
|
|
33
|
+
// 'auto'), not fall through to 'other' (disposition 'fatal', "needs you").
|
|
34
|
+
test('undici stream-drop texts classify as connection/auto, not other/fatal', () => {
|
|
35
|
+
for (const msg of ['terminated', 'other side closed', 'premature close']) {
|
|
36
|
+
const c = classify('pi→provider', msg);
|
|
37
|
+
assert.equal(c.kind, 'connection', `"${msg}" must classify as connection`);
|
|
38
|
+
assert.equal(c.disposition, 'auto', `"${msg}" must be auto-retried, not fatal`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
test('"terminated" matches as a whole word only (no false positive on substrings)', () => {
|
|
42
|
+
// \bterminated\b must not match e.g. "unterminated" as a stray substring hit —
|
|
43
|
+
// guard the word-boundary choice explicitly.
|
|
44
|
+
assert.notEqual(classify('pi→provider', 'preterminated').kind, 'connection');
|
|
45
|
+
});
|
|
@@ -17,6 +17,11 @@ export declare class SessionManager {
|
|
|
17
17
|
static open(path: string): SessionManager;
|
|
18
18
|
getSessionId(): string;
|
|
19
19
|
getSessionFile(): string;
|
|
20
|
+
/** The directory containing the session file — the real SessionManager's
|
|
21
|
+
* getSessionDir(). The broker prewarms the session-list cache with it at boot
|
|
22
|
+
* (broker.ts runBroker), so the fixture must expose it or every broker boot
|
|
23
|
+
* crashes before it binds view.sock. */
|
|
24
|
+
getSessionDir(): string;
|
|
20
25
|
private leafId;
|
|
21
26
|
/** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
|
|
22
27
|
* resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
|
|
@@ -124,6 +129,7 @@ declare class FakeSession {
|
|
|
124
129
|
}>;
|
|
125
130
|
getSessionId: () => string;
|
|
126
131
|
getSessionFile: () => string;
|
|
132
|
+
getSessionDir: () => string;
|
|
127
133
|
};
|
|
128
134
|
get sessionId(): string;
|
|
129
135
|
get sessionFile(): string | undefined;
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// <nodeDir>/fake-pi.cmd — harness writes ONE JSON command (turn|stop|dialog)
|
|
31
31
|
// <nodeDir>/fake-pi.dialog.jsonl — one line per resolved unattended dialog (§5.4)
|
|
32
32
|
import { writeFileSync, readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, } from 'node:fs';
|
|
33
|
-
import { join } from 'node:path';
|
|
33
|
+
import { join, dirname } from 'node:path';
|
|
34
34
|
import { randomUUID } from 'node:crypto';
|
|
35
35
|
import { DefaultResourceLoader, getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
36
36
|
/** Test seam (M-1 regression): a FRESH-start kickoff prompt carrying this token
|
|
@@ -100,6 +100,13 @@ export class SessionManager {
|
|
|
100
100
|
getSessionFile() {
|
|
101
101
|
return this.sessionFile;
|
|
102
102
|
}
|
|
103
|
+
/** The directory containing the session file — the real SessionManager's
|
|
104
|
+
* getSessionDir(). The broker prewarms the session-list cache with it at boot
|
|
105
|
+
* (broker.ts runBroker), so the fixture must expose it or every broker boot
|
|
106
|
+
* crashes before it binds view.sock. */
|
|
107
|
+
getSessionDir() {
|
|
108
|
+
return dirname(this.sessionFile);
|
|
109
|
+
}
|
|
103
110
|
leafId = null;
|
|
104
111
|
/** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
|
|
105
112
|
* resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
|
|
@@ -285,6 +292,7 @@ class FakeSession {
|
|
|
285
292
|
getUserMessagesForForking: () => this.tree.getUserMessagesForForking(),
|
|
286
293
|
getSessionId: () => this.sm.getSessionId(),
|
|
287
294
|
getSessionFile: () => this.sm.getSessionFile(),
|
|
295
|
+
getSessionDir: () => this.sm.getSessionDir(),
|
|
288
296
|
};
|
|
289
297
|
}
|
|
290
298
|
get sessionId() {
|
|
@@ -142,10 +142,15 @@ test('G6 — zero-viewer dialog resolves immediately; an unanswered attended dia
|
|
|
142
142
|
assert.equal(r1[base].resolved, false, 'G6a: zero-viewer dialog resolves to the default (deny)');
|
|
143
143
|
assert.ok(r1[base].ms < 2000, `G6a: resolved immediately (noOp), not after the 5000ms timeout — got ${r1[base].ms}ms`);
|
|
144
144
|
// (b) controller attached but silent → the broker resolves on the SHORT explicit
|
|
145
|
-
// per-dialog timeout (800ms), never the 120s default.
|
|
145
|
+
// per-dialog timeout (800ms), never the 120s default. On timeout the broker also
|
|
146
|
+
// broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so the
|
|
147
|
+
// controller's overlay (which has no independent timer) is torn down by id.
|
|
146
148
|
const c = await ctrl('g6-ctrl');
|
|
147
149
|
h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
|
|
148
|
-
await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
|
|
150
|
+
const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
|
|
151
|
+
const reqId = req.id;
|
|
152
|
+
const dismiss = await c.waitFrame((f) => f.type === 'extension_ui_dismiss' && f.id === reqId, 'G6b broker emits a correlated extension_ui_dismiss on timeout');
|
|
153
|
+
assert.equal(dismiss.id, reqId, 'G6b: the dismiss frame carries the timed-out dialog id, not another');
|
|
149
154
|
const r2 = await awaitDialogs(base + 2, 'G6b attended dialog resolved on timeout');
|
|
150
155
|
assert.equal(r2[base + 1].resolved, false, 'G6b: an unanswered attended dialog resolves to the default (deny)');
|
|
151
156
|
assert.ok(r2[base + 1].ms >= 600 && r2[base + 1].ms < 5000, `G6b: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${r2[base + 1].ms}ms`);
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
import { test, before, after } from 'node:test';
|
|
63
63
|
import assert from 'node:assert/strict';
|
|
64
64
|
import { spawn } from 'node:child_process';
|
|
65
|
-
import {
|
|
65
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
66
66
|
import { tmpdir } from 'node:os';
|
|
67
67
|
import { join } from 'node:path';
|
|
68
68
|
import { createHarness } from './helpers/harness.js';
|
|
@@ -180,9 +180,20 @@ async function spawnBrokerWithSharedGroupDescendant(tag, descendantScript = 'sle
|
|
|
180
180
|
if (leader.pid == null)
|
|
181
181
|
throw new Error('failed to spawn fake broker leader');
|
|
182
182
|
trackPid(leader.pid);
|
|
183
|
-
await h.waitFor(() =>
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
const descendantPid = await h.waitFor(() => {
|
|
184
|
+
try {
|
|
185
|
+
const record = readFileSync(pidFile, 'utf8');
|
|
186
|
+
if (!/^\d+\n$/.test(record))
|
|
187
|
+
return false;
|
|
188
|
+
const pid = Number(record.slice(0, -1));
|
|
189
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : false;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error.code === 'ENOENT')
|
|
193
|
+
return false;
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}, { label: `${tag}: valid descendant pid written` });
|
|
186
197
|
trackPid(descendantPid);
|
|
187
198
|
await h.waitFor(() => isPidAlive(descendantPid), { label: `${tag}: descendant alive before teardown` });
|
|
188
199
|
return { leaderPid: leader.pid, descendantPid };
|
|
@@ -10,10 +10,39 @@
|
|
|
10
10
|
// buildRoot()-assembled tree (safe here — this is a test, not the attach
|
|
11
11
|
// bundle) and asserts the set of leaves declaring `suppressOutput: true`
|
|
12
12
|
// matches the registry exactly.
|
|
13
|
-
import { test } from 'node:test';
|
|
13
|
+
import { test, before, after } from 'node:test';
|
|
14
14
|
import assert from 'node:assert/strict';
|
|
15
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
15
18
|
import { buildRoot } from '../../build-root.js';
|
|
16
19
|
import { SUPPRESS_OUTPUT_LEAVES } from '../preview-registry.js';
|
|
20
|
+
import { resetScopeCache } from '../scope.js';
|
|
21
|
+
// buildRoot() composes external command-plugin branches from the effective
|
|
22
|
+
// enabled plugins (user scope + project ancestors). Pin HOME + profile to an
|
|
23
|
+
// isolated no-plugin home so ambient machine state can't inject an external
|
|
24
|
+
// leaf and make this drift guard nondeterministic.
|
|
25
|
+
let home;
|
|
26
|
+
let realHome;
|
|
27
|
+
let realProfile;
|
|
28
|
+
before(() => {
|
|
29
|
+
realHome = process.env['HOME'];
|
|
30
|
+
realProfile = process.env['CRTR_PROFILE_ID'];
|
|
31
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-preview-registry-'));
|
|
32
|
+
process.env['HOME'] = home;
|
|
33
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
34
|
+
resetScopeCache();
|
|
35
|
+
});
|
|
36
|
+
after(() => {
|
|
37
|
+
if (realHome !== undefined)
|
|
38
|
+
process.env['HOME'] = realHome;
|
|
39
|
+
else
|
|
40
|
+
delete process.env['HOME'];
|
|
41
|
+
if (realProfile !== undefined)
|
|
42
|
+
process.env['CRTR_PROFILE_ID'] = realProfile;
|
|
43
|
+
rmSync(home, { recursive: true, force: true });
|
|
44
|
+
resetScopeCache();
|
|
45
|
+
});
|
|
17
46
|
test('preview-registry.ts matches every leaf declaring help.preview.suppressOutput', async () => {
|
|
18
47
|
const root = await buildRoot();
|
|
19
48
|
const declared = new Set();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|