@termwright/conformance 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/index.d.ts +351 -0
- package/dist/index.js +1129 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/fixtures/adversarial-peer.mjs +567 -0
- package/src/fixtures/generic-app.mjs +236 -0
- package/src/fixtures/ink-probe-app.mjs +23 -0
- package/src/fixtures/prompt-app.mjs +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gorce-ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# @termwright/conformance
|
|
2
|
+
|
|
3
|
+
The suites that decide whether an implementation of termwright is actually one:
|
|
4
|
+
runnable fixtures, the driver conformance matrix from the origin spec (§20), and
|
|
5
|
+
an **adapter contract suite you can run against any adapter, in any language**.
|
|
6
|
+
|
|
7
|
+
Nothing depends on this package; it is allowed to depend on everything.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add -D @termwright/conformance
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`vitest` is an optional peer dependency, needed only to run the exported suite.
|
|
16
|
+
|
|
17
|
+
## Certifying an adapter
|
|
18
|
+
|
|
19
|
+
The only thing an adapter author writes is the binding between their app and the
|
|
20
|
+
suite. It drives the adapter as a subprocess and looks at bytes and frames, so a
|
|
21
|
+
Python, Go or Rust adapter certifies exactly like the TypeScript one.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// my-adapter.conformance.test.ts
|
|
25
|
+
import { runAdapterConformance } from '@termwright/conformance';
|
|
26
|
+
|
|
27
|
+
await runAdapterConformance({
|
|
28
|
+
name: 'termwright-py',
|
|
29
|
+
spawn: () => ({ command: ['python', 'examples/demo_app.py'] }),
|
|
30
|
+
// Optional: the same UI with the adapter compiled out. When given, the
|
|
31
|
+
// dormant run is compared against it byte for byte.
|
|
32
|
+
baseline: () => ({ command: ['python', 'examples/demo_app.py'], env: { PLAIN: '1' } }),
|
|
33
|
+
ready: 'Ready',
|
|
34
|
+
interaction: { input: '\t', expect: '[Save]' },
|
|
35
|
+
quit: { input: '', exitCode: 0 },
|
|
36
|
+
columns: 80,
|
|
37
|
+
rows: 24,
|
|
38
|
+
expectAbsoluteBounds: true,
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The byte-for-byte comparison covers the **startup** stream, with nothing
|
|
43
|
+
written to the child: a pseudo-terminal echoes the suite's own keystrokes, so a
|
|
44
|
+
stream containing our input compares the tty's timing rather than the adapter's
|
|
45
|
+
output (measured on the Ink fixture: 3 mismatches in 30 pairs with input —
|
|
46
|
+
always a stray `0x09`, the tab the suite itself sent — and 0 in 40 without).
|
|
47
|
+
|
|
48
|
+
Two requirements the registration has to respect, because the suite exercises
|
|
49
|
+
the app rather than mocking it: `interaction.input` is sent **more than once**,
|
|
50
|
+
so pick something whose repetition is harmless; and `quit.input` must work from
|
|
51
|
+
**any** state that repetition can reach — a key that quits only while one widget
|
|
52
|
+
has focus is not a quit input.
|
|
53
|
+
|
|
54
|
+
Pass `requires` to declare the toolchain the adapter needs. When the probe
|
|
55
|
+
fails, the whole registration skips and the reason appears in the block's name,
|
|
56
|
+
exactly as a missing pseudo-terminal does:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
requires: {
|
|
60
|
+
probe: ['python3', '-c', 'import termwright, textual'],
|
|
61
|
+
label: 'python3 with termwright and textual installed',
|
|
62
|
+
},
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
An adapter that announces the `logs` capability declares how to exercise it,
|
|
66
|
+
and the obligations above are then asserted rather than skipped:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
logs: { input: 'l', expect: 'conformance log record' },
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`input` is optional: omit it for an app that logs on its own, as the tview
|
|
73
|
+
example does at startup, and the obligation waits for the record instead of
|
|
74
|
+
provoking one.
|
|
75
|
+
|
|
76
|
+
Conventions the fixture has to opt into — an annotated test id, an empty
|
|
77
|
+
textbox, a container with no label — are declared under `conventions`; the rest
|
|
78
|
+
run for every adapter:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
conventions: {
|
|
82
|
+
emptyTextboxTestId: 'reason',
|
|
83
|
+
readmePath: 'clients/go/README.md',
|
|
84
|
+
},
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Deviations are **not** declared here. They are read from the adapter's own
|
|
88
|
+
`## Deviations` section, which is where rule 6 puts them and where a user reads
|
|
89
|
+
them; repeating them in a registration would give two copies of one fact that
|
|
90
|
+
eventually disagree. That gives three outcomes rather than two: compliant, a
|
|
91
|
+
failure the README declares (a documented limitation, not an error), and a
|
|
92
|
+
failure it does not (an error).
|
|
93
|
+
|
|
94
|
+
Every run writes a per-adapter roll-up of those declarations, which
|
|
95
|
+
`pnpm conformance` prints under the matrix. It is generated rather than
|
|
96
|
+
maintained: a hand-written table of per-adapter gaps went stale within a round
|
|
97
|
+
of being written, and a stale overview in a document people trust is worse than
|
|
98
|
+
none.
|
|
99
|
+
|
|
100
|
+
Rules 1, 2 and 4 cannot be judged in full from outside a subprocess, so the
|
|
101
|
+
README check is advisory: a missing `## Deviations` heading writes a warning to
|
|
102
|
+
stderr rather than failing the run.
|
|
103
|
+
|
|
104
|
+
It checks the five obligations an adapter has:
|
|
105
|
+
|
|
106
|
+
| Obligation | What is asserted |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Dormant rule | Without `TERMWRIGHT_ENDPOINT` it opens no channel and writes no marker; with `baseline`, byte-for-byte identical startup output |
|
|
109
|
+
| Tree before input | Once the handshake completes and *before any input*, the tree is non-empty and has at least one node a locator could address. Opt out with `treeBeforeInput: {required: false, reason}` |
|
|
110
|
+
| Handshake | `hello` first and once, correct protocol id, non-empty adapter identity, capabilities from the closed set |
|
|
111
|
+
| Snapshot validity | Every snapshot passes `validateSnapshot`, carries this session's id, has resolvable parents and monotonic revisions |
|
|
112
|
+
| Revision ordering | For each revision: snapshot → `revision-commit` → a marker that verifies against the session token, markers strictly increasing |
|
|
113
|
+
| Channel loss | Cutting the socket leaves the application rendering and alive, and the adapter does not reconnect |
|
|
114
|
+
| Logs | An adapter that did not announce `logs` sends none. One that declares them in the registration must deliver a record whose `seq` is unique and increasing and whose message never appears on the terminal |
|
|
115
|
+
| Deltas (when announced) | With `subscribe: 'diffs'`, the deltas an adapter emits compose — through the protocol's own `applyTreeDelta` — to the same tree it reports when asked with `get-tree` |
|
|
116
|
+
| Conventions | The machine-checkable half of the protocol README's "Adapter semantics conventions": containers are not named from their content (rule 2), an annotated test id reaches the wire (rule 3), an empty textbox publishes `value: ''` and no value is derived outside `{textbox, progressbar}` or from a boolean (rule 5) |
|
|
117
|
+
|
|
118
|
+
`await` it at the top level: `vitest` is imported dynamically so the package can
|
|
119
|
+
also be used from a plain script.
|
|
120
|
+
|
|
121
|
+
## Fixtures
|
|
122
|
+
|
|
123
|
+
`CONFORMANCE_FIXTURES` returns absolute paths to programs you can launch with
|
|
124
|
+
`node <path>`:
|
|
125
|
+
|
|
126
|
+
| Fixture | Purpose | Dependencies |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `generic()` | Uninstrumented app: menu, colours, mouse/paste/focus modes, Unicode, alternate screen, scrollback (§20.1) | none |
|
|
129
|
+
| `prompt()` | Shell-shaped app emitting OSC 133 marks; `--marks=off` suppresses them, `--work=<ms>` sets the command duration | none |
|
|
130
|
+
| `adversarialPeer()` | Raw wire peer; takes a scenario name as `argv[2]` (§20.3) | none |
|
|
131
|
+
| `inkProbe()` | Ordinary `ink.render` app launched through the zero-config Ink probe | `ink`, `react`, `@termwright/probe-ink` |
|
|
132
|
+
|
|
133
|
+
The first three import nothing at all — the adversarial peer re-derives the
|
|
134
|
+
framing and the marker MAC from the specification rather than importing
|
|
135
|
+
`@termwright/protocol`, so a drift between spec and implementation shows up
|
|
136
|
+
instead of cancelling out.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { CONFORMANCE_FIXTURES } from '@termwright/conformance';
|
|
140
|
+
import { launchTerminal } from '@termwright/driver';
|
|
141
|
+
|
|
142
|
+
const terminal = await launchTerminal({ command: ['node', CONFORMANCE_FIXTURES.generic()] });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Running the matrix
|
|
146
|
+
|
|
147
|
+
The MCP suite drives `@termwright/mcp` over real HTTP with several concurrent
|
|
148
|
+
sessions, and checks close ownership against real pids rather than against the
|
|
149
|
+
registry's bookkeeping — a registry can forget a session while its terminals
|
|
150
|
+
keep running, and only a pid probed afterwards tells the two apart.
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
pnpm --filter @termwright/conformance conformance # every suite, one matrix
|
|
154
|
+
pnpm --filter @termwright/conformance test # plain vitest
|
|
155
|
+
pnpm --filter @termwright/conformance test:hostile # adversarial suite, 128 MB heap cap
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
area spec result tests time
|
|
160
|
+
generic fallback §20.1 pass 10/10 2.0s
|
|
161
|
+
hostile peer §20.3 pass 25/25 14.5s
|
|
162
|
+
interaction §20.4 pass 12/12 2.6s
|
|
163
|
+
readiness + env §5.3 pass 10/10 2.2s
|
|
164
|
+
adapter contract (py/go) §7 pass, 8 skip 6/14 0.6s
|
|
165
|
+
hostile peer @ 128 MB heap §10 pass 25/25 14.4s
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Certifying the py/go rows needs their toolchains on the runner
|
|
169
|
+
(`pip install -e clients/python[dev]` and a Go toolchain); without them those
|
|
170
|
+
rows skip honestly, with the probe's failure on stderr.
|
|
171
|
+
|
|
172
|
+
A partly-skipped area is reported as such rather than as a clean pass: the
|
|
173
|
+
language adapters skip their whole registration when the toolchain is absent,
|
|
174
|
+
and a matrix that hid it would claim coverage the machine never produced.
|
|
175
|
+
|
|
176
|
+
Sessions launch with the driver's secret-safe `envMode: 'replace'` default, so
|
|
177
|
+
a fixture only sees the documented allowlist plus what a suite declares.
|
|
178
|
+
|
|
179
|
+
Every suite needs a pseudo-terminal and skips itself where none can be opened;
|
|
180
|
+
`TERMWRIGHT_SKIP_PTY=1` skips them explicitly. A run where everything skipped
|
|
181
|
+
says so rather than reporting success.
|
|
182
|
+
|
|
183
|
+
## Why a second, smaller driver
|
|
184
|
+
|
|
185
|
+
`AdapterProbe` speaks the protocol itself — endpoint, handshake, framing, marker
|
|
186
|
+
verification — instead of using `@termwright/driver`. The driver deliberately
|
|
187
|
+
hides frame ordering behind a settled tree, which is the right API for testing
|
|
188
|
+
applications and the wrong one for testing adapters. The probe is exported for
|
|
189
|
+
checks the suite does not cover; it never renders, locates or acts.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { AdapterToDriverMessage, LogRecord, TreeDelta, SemanticSnapshot } from '@termwright/protocol';
|
|
2
|
+
import { LaunchOptions, TerminalHarness } from '@termwright/driver';
|
|
3
|
+
|
|
4
|
+
/** How a fixture is started. Everything else about it is opaque to the probe. */
|
|
5
|
+
interface AdapterCommand {
|
|
6
|
+
readonly command: readonly string[];
|
|
7
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
8
|
+
readonly cwd?: string;
|
|
9
|
+
}
|
|
10
|
+
/** One message the adapter sent, stamped with the stdout position at arrival. */
|
|
11
|
+
interface RecordedMessage {
|
|
12
|
+
readonly message: AdapterToDriverMessage;
|
|
13
|
+
/** Bytes of stdout the probe had received when this frame was parsed. */
|
|
14
|
+
readonly stdoutBytes: number;
|
|
15
|
+
readonly atMs: number;
|
|
16
|
+
}
|
|
17
|
+
/** One verified render marker found in stdout. */
|
|
18
|
+
interface RecordedMarker {
|
|
19
|
+
readonly revision: number;
|
|
20
|
+
/** Offset of the marker's first byte in the stdout stream. */
|
|
21
|
+
readonly offset: number;
|
|
22
|
+
readonly atMs: number;
|
|
23
|
+
}
|
|
24
|
+
/** A frame the probe refused; a conforming adapter produces none. */
|
|
25
|
+
interface RecordedFault {
|
|
26
|
+
readonly code: string;
|
|
27
|
+
readonly detail: string;
|
|
28
|
+
}
|
|
29
|
+
interface ProbeOptions {
|
|
30
|
+
readonly columns?: number;
|
|
31
|
+
readonly rows?: number;
|
|
32
|
+
/** Set `false` to withhold the instrumentation env — the dormant-run case. */
|
|
33
|
+
readonly instrument?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* What the probe asks the adapter to push. `'diffs'` is a preference, not a
|
|
36
|
+
* prohibition: an adapter may still send a full tree when a delta would not
|
|
37
|
+
* pay for itself, and the first publication always is one.
|
|
38
|
+
*/
|
|
39
|
+
readonly subscribe?: 'snapshots' | 'diffs';
|
|
40
|
+
}
|
|
41
|
+
/** Everything the probe observed, readable while the child is still running. */
|
|
42
|
+
interface ProbeObservation {
|
|
43
|
+
readonly messages: readonly RecordedMessage[];
|
|
44
|
+
readonly markers: readonly RecordedMarker[];
|
|
45
|
+
readonly faults: readonly RecordedFault[];
|
|
46
|
+
readonly connections: number;
|
|
47
|
+
readonly stdout: Uint8Array;
|
|
48
|
+
/** Raw bytes decoded as UTF-8: what was written, in order, escapes included. */
|
|
49
|
+
readonly text: string;
|
|
50
|
+
/** The visible grid, one row per line — what a user would actually see. */
|
|
51
|
+
readonly screen: string;
|
|
52
|
+
/** Application log records the adapter sent, in arrival order. */
|
|
53
|
+
readonly logs: readonly LogRecord[];
|
|
54
|
+
/** Deltas the adapter sent, in arrival order. */
|
|
55
|
+
readonly deltas: readonly TreeDelta[];
|
|
56
|
+
/**
|
|
57
|
+
* The tree obtained by composing every snapshot and delta received, in order,
|
|
58
|
+
* with the protocol's own `applyTreeDelta`. This is the oracle an adapter's
|
|
59
|
+
* deltas are checked against: a producer that also composed would only prove
|
|
60
|
+
* it agrees with itself.
|
|
61
|
+
*/
|
|
62
|
+
readonly composed: SemanticSnapshot | null;
|
|
63
|
+
/** Why composition stopped, when it did. A conforming adapter produces none. */
|
|
64
|
+
readonly compositionError: string | null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Runs one fixture under a pseudo-terminal with a protocol endpoint attached.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* const probe = await AdapterProbe.start({ command: ['node', 'app.mjs'] }, {});
|
|
72
|
+
* await probe.waitForText('Ready');
|
|
73
|
+
* await probe.write('\t');
|
|
74
|
+
* const { messages, markers } = probe.observe();
|
|
75
|
+
* await probe.stop();
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
declare class AdapterProbe {
|
|
79
|
+
#private;
|
|
80
|
+
readonly sessionId: string;
|
|
81
|
+
readonly token: string;
|
|
82
|
+
private constructor();
|
|
83
|
+
/** Creates the endpoint (unless dormant), then spawns the fixture. */
|
|
84
|
+
static start(command: AdapterCommand, options?: ProbeOptions): Promise<AdapterProbe>;
|
|
85
|
+
/** Everything observed so far. Safe to call at any point. */
|
|
86
|
+
observe(): ProbeObservation;
|
|
87
|
+
/** The visible grid as text, trailing whitespace trimmed per row. */
|
|
88
|
+
screenText(): string;
|
|
89
|
+
/** The child's exit status, or `null` while it is still running. */
|
|
90
|
+
get exitStatus(): {
|
|
91
|
+
code: number | null;
|
|
92
|
+
signal: string | null;
|
|
93
|
+
} | null;
|
|
94
|
+
/** Writes raw bytes to the child, exactly as a terminal would. */
|
|
95
|
+
write(input: string): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Resolves once `needle` appears on the rendered grid.
|
|
98
|
+
*
|
|
99
|
+
* Matching the byte stream instead would only work for adapters that happen
|
|
100
|
+
* to write their text contiguously: a framework that positions each run of
|
|
101
|
+
* cells never emits `focus: reject` as those twelve bytes in a row.
|
|
102
|
+
*/
|
|
103
|
+
waitForText(needle: string | RegExp, timeoutMs?: number): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Resolves once `predicate` holds over the current observation.
|
|
106
|
+
*
|
|
107
|
+
* `what` names the thing being waited for, and the failure carries what the
|
|
108
|
+
* probe could see when it gave up. "Condition never became true" is not a
|
|
109
|
+
* result anybody can act on: an adapter that never connected, one that
|
|
110
|
+
* connected and published nothing, and one whose binary died all produce it,
|
|
111
|
+
* and only the observation tells them apart.
|
|
112
|
+
*/
|
|
113
|
+
waitFor(predicate: (observation: ProbeObservation) => boolean, timeoutMs?: number, what?: string): Promise<void>;
|
|
114
|
+
/** What the probe has seen so far, for a failure that has to explain itself. */
|
|
115
|
+
describe(): string;
|
|
116
|
+
/** Waits for the child to exit and returns its status. */
|
|
117
|
+
waitForExit(timeoutMs?: number): Promise<{
|
|
118
|
+
code: number | null;
|
|
119
|
+
signal: string | null;
|
|
120
|
+
}>;
|
|
121
|
+
/** Cuts the semantic channel without touching the child: the disconnect case. */
|
|
122
|
+
cutChannel(): void;
|
|
123
|
+
/** Stops the child and releases the endpoint. Idempotent. */
|
|
124
|
+
stop(): Promise<void>;
|
|
125
|
+
/**
|
|
126
|
+
* Asks the adapter for a full tree and resolves with it.
|
|
127
|
+
*
|
|
128
|
+
* This is what turns composition into a check rather than a belief: the
|
|
129
|
+
* locally composed tree is compared against one the adapter built itself.
|
|
130
|
+
*/
|
|
131
|
+
requestTree(timeoutMs?: number): Promise<SemanticSnapshot | null>;
|
|
132
|
+
}
|
|
133
|
+
/** The marker prefix, re-exported so suites can assert on dormant output. */
|
|
134
|
+
declare const MARKER_TEXT_PREFIX = "\u001B]8487;twm;";
|
|
135
|
+
|
|
136
|
+
/** How to start, drive and stop the adapter under test. */
|
|
137
|
+
interface AdapterConformanceOptions {
|
|
138
|
+
/** Name of the adapter, used in the test titles. */
|
|
139
|
+
readonly name: string;
|
|
140
|
+
/** Command that starts the instrumented application. */
|
|
141
|
+
spawn(): AdapterCommand;
|
|
142
|
+
/**
|
|
143
|
+
* Optional command rendering the same UI with the adapter compiled out. When
|
|
144
|
+
* given, the dormant run is compared against it byte for byte — the strongest
|
|
145
|
+
* form of the dormant rule. Without it, a dormant run is only checked for
|
|
146
|
+
* silence on the wire.
|
|
147
|
+
*/
|
|
148
|
+
baseline?(): AdapterCommand;
|
|
149
|
+
/** Text that proves the first frame reached the terminal. */
|
|
150
|
+
readonly ready: string | RegExp;
|
|
151
|
+
/**
|
|
152
|
+
* An input that changes the screen, and the text that proves it landed.
|
|
153
|
+
*
|
|
154
|
+
* The suite sends it **more than once** — a dormant run, a run that has to
|
|
155
|
+
* produce a second revision, and a run after the channel was cut all need a
|
|
156
|
+
* render. Pick something whose repetition is harmless.
|
|
157
|
+
*/
|
|
158
|
+
readonly interaction: {
|
|
159
|
+
readonly input: string;
|
|
160
|
+
readonly expect: string | RegExp;
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* An input that makes the application exit, and the status it exits with.
|
|
164
|
+
*
|
|
165
|
+
* It must work from **any** state repeated `interaction` can reach. A key
|
|
166
|
+
* that quits only while a particular widget has focus is not a quit input:
|
|
167
|
+
* the tview example's documented `q` types into its text field once focus has
|
|
168
|
+
* cycled that far, so its registration uses Ctrl+C instead.
|
|
169
|
+
*/
|
|
170
|
+
readonly quit: {
|
|
171
|
+
readonly input: string;
|
|
172
|
+
readonly exitCode?: number;
|
|
173
|
+
};
|
|
174
|
+
readonly columns?: number;
|
|
175
|
+
readonly rows?: number;
|
|
176
|
+
/** Assert that published bounds are viewport-absolute (an `absolute-bounds` claim). */
|
|
177
|
+
readonly expectAbsoluteBounds?: boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Opt out of the "publishes a tree before any input" obligation.
|
|
180
|
+
*
|
|
181
|
+
* By default an adapter must publish a non-empty tree once the handshake
|
|
182
|
+
* completes, with no input sent — an app that is addressable only after the
|
|
183
|
+
* first keystroke is not addressable at all to a driver that has just
|
|
184
|
+
* launched it. Some apps legitimately render nothing until an event arrives;
|
|
185
|
+
* pass a reason, which is printed in the test title so the exemption stays
|
|
186
|
+
* visible rather than becoming folklore.
|
|
187
|
+
*/
|
|
188
|
+
readonly treeBeforeInput?: {
|
|
189
|
+
readonly required: false;
|
|
190
|
+
readonly reason: string;
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* How to make the application log, for an adapter that announces the `logs`
|
|
194
|
+
* capability. Without it the log obligations are skipped; with it they are
|
|
195
|
+
* asserted, and an adapter that announces `logs` but never delivers one
|
|
196
|
+
* fails here rather than in a user's test.
|
|
197
|
+
*/
|
|
198
|
+
/**
|
|
199
|
+
* How to check the normative adapter conventions (protocol README, "Adapter
|
|
200
|
+
* semantics conventions"). Rules 1, 2 and 4 are largely judgement calls from
|
|
201
|
+
* outside; what is listed here is what a subprocess can actually observe.
|
|
202
|
+
*
|
|
203
|
+
* A rule an adapter cannot follow is a *declared deviation*, not a failure:
|
|
204
|
+
* name it in `deviations` and the matching check is skipped with the reason
|
|
205
|
+
* in the test title, so the exemption stays visible instead of becoming
|
|
206
|
+
* folklore.
|
|
207
|
+
*/
|
|
208
|
+
readonly conventions?: {
|
|
209
|
+
/** A test id the fixture sets by author annotation (rule 3). */
|
|
210
|
+
readonly annotatedTestId?: string;
|
|
211
|
+
/** A textbox whose field is empty, to prove `value: ''` (rule 5). */
|
|
212
|
+
readonly emptyTextboxTestId?: string;
|
|
213
|
+
/** A container with no name of its own, wrapping text (rule 2). */
|
|
214
|
+
readonly unnamedContainerTestId?: string;
|
|
215
|
+
/**
|
|
216
|
+
* Test ids whose `value` is author-annotated. The role gate in rule 5
|
|
217
|
+
* bounds *derived* values; an annotation may put one on any role, and only
|
|
218
|
+
* the registration knows which is which.
|
|
219
|
+
*/
|
|
220
|
+
readonly annotatedValues?: readonly string[];
|
|
221
|
+
/**
|
|
222
|
+
* The adapter's README. Its `## Deviations` section is the single source of
|
|
223
|
+
* truth for what this adapter cannot do (rule 6), so a declared limitation
|
|
224
|
+
* is read from there rather than repeated in the registration — two copies
|
|
225
|
+
* of the same fact disagree eventually, and the README is the one a user
|
|
226
|
+
* reads.
|
|
227
|
+
*/
|
|
228
|
+
readonly readmePath?: string;
|
|
229
|
+
};
|
|
230
|
+
readonly logs?: {
|
|
231
|
+
/**
|
|
232
|
+
* Input that makes the application write a record. Omit it for an app that
|
|
233
|
+
* logs on its own (at startup, say) — the obligation then waits for a
|
|
234
|
+
* record rather than provoking one.
|
|
235
|
+
*/
|
|
236
|
+
readonly input?: string;
|
|
237
|
+
/** A substring of the logged message, used to prove it stayed off-screen. */
|
|
238
|
+
readonly expect: string;
|
|
239
|
+
};
|
|
240
|
+
/** How long the handshake may take. Default 10 s. */
|
|
241
|
+
readonly timeoutMs?: number;
|
|
242
|
+
/**
|
|
243
|
+
* A command that must succeed before this adapter can be certified here —
|
|
244
|
+
* its interpreter, or a build step that produces the binary `spawn` runs.
|
|
245
|
+
* When it fails the whole suite skips and the reason is in the block's name,
|
|
246
|
+
* exactly as a missing pseudo-terminal does.
|
|
247
|
+
*/
|
|
248
|
+
readonly requires?: {
|
|
249
|
+
readonly probe: readonly string[];
|
|
250
|
+
readonly label: string;
|
|
251
|
+
readonly cwd?: string;
|
|
252
|
+
readonly timeoutMs?: number;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Rule numbers declared in an adapter's `## Deviations` section.
|
|
257
|
+
*
|
|
258
|
+
* Three shapes are in use and all are accepted, because the suite should not
|
|
259
|
+
* dictate anyone's prose: `**Rule 2 — …**` (a heading per entry, Ink),
|
|
260
|
+
* `- **…** (rule 3).` (the number at the end of a bullet, the language
|
|
261
|
+
* clients), and a markdown table whose first column is `2 — …` (OpenTUI).
|
|
262
|
+
*
|
|
263
|
+
* Entries that name no rule are kept under `other`: they are still declared
|
|
264
|
+
* limitations, and dropping them would make the roll-up quietly incomplete.
|
|
265
|
+
*/
|
|
266
|
+
declare function parseDeclaredDeviations(readme: string): Map<string, string[]>;
|
|
267
|
+
/**
|
|
268
|
+
* Registers the adapter contract suite for one adapter.
|
|
269
|
+
*
|
|
270
|
+
* Call it at the top level of a test file; it declares its own `describe`.
|
|
271
|
+
*
|
|
272
|
+
* `vitest` is imported dynamically, so the package stays importable from a
|
|
273
|
+
* plain script that only wants the fixture paths or the probe. That is why the
|
|
274
|
+
* function is async: `await` it at the top level of the test file, which is
|
|
275
|
+
* where vitest collects the suite from.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* await runAdapterConformance({
|
|
280
|
+
* name: 'my-framework-probe',
|
|
281
|
+
* spawn: () => ({ command: ['node', 'app.mjs'] }),
|
|
282
|
+
* ready: 'Ready',
|
|
283
|
+
* interaction: { input: '\t', expect: '[Save]' },
|
|
284
|
+
* quit: { input: 'q', exitCode: 0 },
|
|
285
|
+
* });
|
|
286
|
+
* ```
|
|
287
|
+
*/
|
|
288
|
+
declare function runAdapterConformance(options: AdapterConformanceOptions): Promise<void>;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Absolute path of a fixture that ships with this package.
|
|
292
|
+
*
|
|
293
|
+
* The fixtures are shipped as sources rather than bundled, because they are
|
|
294
|
+
* meant to be launched as `node <path>` by suites in other packages and in
|
|
295
|
+
* other languages' CI. They are therefore located from the package root, which
|
|
296
|
+
* is the one anchor that is the same whether this module was loaded from `src/`
|
|
297
|
+
* during development or from the bundled `dist/`.
|
|
298
|
+
*/
|
|
299
|
+
declare function fixturePath(name: string): string;
|
|
300
|
+
/** The fixtures published for adapter authors and other packages' suites. */
|
|
301
|
+
declare const CONFORMANCE_FIXTURES: Readonly<{
|
|
302
|
+
/** Uninstrumented app: proves the generic fallback (§20.1). */
|
|
303
|
+
generic: () => string;
|
|
304
|
+
/** Shell-shaped app emitting OSC 133 marks; `--marks=off` suppresses them. */
|
|
305
|
+
prompt: () => string;
|
|
306
|
+
/** Normal-render Ink app used to exercise launch-time probe attachment. */
|
|
307
|
+
inkProbe: () => string;
|
|
308
|
+
/** Hostile wire peer; takes a scenario name as its first argument (§20.3). */
|
|
309
|
+
adversarialPeer: () => string;
|
|
310
|
+
}>;
|
|
311
|
+
/**
|
|
312
|
+
* Whether this machine can open a pseudo-terminal at all.
|
|
313
|
+
*
|
|
314
|
+
* @returns `false` when `TERMWRIGHT_SKIP_PTY=1` or spawning a trivial child
|
|
315
|
+
* through the PTY backend throws. Probed once and cached.
|
|
316
|
+
*/
|
|
317
|
+
declare function ptyAvailable(): boolean;
|
|
318
|
+
/** `process.env` with the `undefined` values dropped, as PTY spawning requires. */
|
|
319
|
+
declare function environment(extra?: Readonly<Record<string, string>>): Record<string, string>;
|
|
320
|
+
/** Options every conformance session shares; suites override what they need. */
|
|
321
|
+
interface FixtureLaunchOptions extends Partial<Omit<LaunchOptions, 'command'>> {
|
|
322
|
+
/** Extra arguments appended to `node <fixture>`. */
|
|
323
|
+
readonly args?: readonly string[];
|
|
324
|
+
/** Attach the zero-config framework probe to the otherwise normal command. */
|
|
325
|
+
readonly probe?: 'ink';
|
|
326
|
+
/**
|
|
327
|
+
* Text that proves the fixture started drawing. Waiting for it here rather
|
|
328
|
+
* than in each suite is what lets the failure be diagnosed: a fixture that
|
|
329
|
+
* printed nothing at all failed to start, which is a very different problem
|
|
330
|
+
* from one that started and drew the wrong thing.
|
|
331
|
+
*/
|
|
332
|
+
readonly ready?: string | RegExp;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* A set of sessions closed together, so one wedged fixture cannot leak a child
|
|
336
|
+
* process into the next test.
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* const sessions = createSessionPool();
|
|
341
|
+
* afterEach(sessions.closeAll);
|
|
342
|
+
* const terminal = await sessions.launch(CONFORMANCE_FIXTURES.generic());
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
interface SessionPool {
|
|
346
|
+
launch(fixture: string, options?: FixtureLaunchOptions): Promise<TerminalHarness>;
|
|
347
|
+
closeAll(): Promise<void>;
|
|
348
|
+
}
|
|
349
|
+
declare function createSessionPool(): SessionPool;
|
|
350
|
+
|
|
351
|
+
export { type AdapterCommand, type AdapterConformanceOptions, AdapterProbe, CONFORMANCE_FIXTURES, type FixtureLaunchOptions, MARKER_TEXT_PREFIX, type ProbeObservation, type ProbeOptions, type RecordedFault, type RecordedMarker, type RecordedMessage, type SessionPool, createSessionPool, environment, fixturePath, parseDeclaredDeviations, ptyAvailable, runAdapterConformance };
|