@ceralive/modem-control 0.1.0 → 1.0.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.
@@ -1,29 +1,26 @@
1
1
  // The D-Bus transport seam implementation.
2
2
  //
3
- // Wraps `@httptoolkit/dbus-native` behind the `DbusTransport` interface: method calls,
4
- // signal subscriptions, and an automatic reconnect loop that re-issues every match rule
5
- // after a bus restart. A single persistent `message` listener fans out to the live
6
- // subscription registry, so subscribing/unsubscribing never grows the connection's
7
- // listener count the 100-cycle leak check depends on this.
3
+ // Wraps `@httptoolkit/dbus-native` behind the `DbusTransport` interface. This module owns the
4
+ // connection lifecycle handshake, a reconnect loop that re-issues every match rule after a
5
+ // bus restart, and teardown and delegates method-call dispatch to `./calls` and signal
6
+ // subscription/match-rule tracking to `./signals`. Its single `message` listener fans out to
7
+ // the signal registry, so subscribing never grows the listener count (the 100-cycle leak check).
8
8
 
9
9
  import { EventEmitter } from 'node:events';
10
- import { decodeBody, encodeBody } from './codec';
10
+ import { CallDispatcher, DEFAULT_CALL_TIMEOUT_MS } from './calls';
11
11
  import {
12
12
  type CreateClientOptions,
13
13
  createClient,
14
- messageType,
15
14
  type RawBus,
16
15
  type RawMessage,
17
- type ReplyContext,
18
16
  } from './dbus-native';
19
17
  import { DisconnectedError, TransportError } from './errors';
18
+ import { SignalRegistry } from './signals';
20
19
  import type {
21
20
  DbusTransport,
22
21
  DbusTransportOptions,
23
- DbusValue,
24
22
  MethodCall,
25
23
  MethodReply,
26
- SignalEvent,
27
24
  SignalListener,
28
25
  SignalSpec,
29
26
  Subscription,
@@ -39,19 +36,6 @@ interface ResolvedReconnect {
39
36
  readonly maxAttempts: number;
40
37
  }
41
38
 
42
- interface SubscriptionRecord {
43
- readonly id: number;
44
- readonly spec: SignalSpec;
45
- readonly listener: SignalListener;
46
- readonly rule: string;
47
- }
48
-
49
- interface PendingCall {
50
- settle(): void;
51
- reject(error: unknown): void;
52
- }
53
-
54
- const DEFAULT_CALL_TIMEOUT_MS = 30_000;
55
39
  // Bound a single connect/auth attempt so a stalled handshake cannot freeze the reconnect
56
40
  // loop. A local unix-socket D-Bus connect completes in milliseconds; 2s is ample headroom
57
41
  // while keeping reconnect responsive after a bus restart.
@@ -65,46 +49,19 @@ const DEFAULT_RECONNECT: ResolvedReconnect = {
65
49
 
66
50
  const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
67
51
 
68
- function buildMatchRule(spec: SignalSpec): string {
69
- const parts = [`type='signal'`, `interface='${spec.interface}'`, `member='${spec.member}'`];
70
- if (spec.path !== undefined) {
71
- parts.push(`path='${spec.path}'`);
72
- }
73
- if (spec.sender !== undefined) {
74
- parts.push(`sender='${spec.sender}'`);
75
- }
76
- return parts.join(',');
77
- }
78
-
79
- function signalMatches(spec: SignalSpec, message: RawMessage): boolean {
80
- if (message.interface !== spec.interface || message.member !== spec.member) {
81
- return false;
82
- }
83
- if (spec.path !== undefined && message.path !== spec.path) {
84
- return false;
85
- }
86
- if (spec.sender !== undefined && message.sender !== spec.sender) {
87
- return false;
88
- }
89
- return true;
90
- }
91
-
92
52
  class DbusTransportImpl implements DbusTransport {
93
53
  readonly #options: DbusTransportOptions;
94
54
  readonly #reconnect: ResolvedReconnect;
95
- readonly #callTimeoutMs: number;
96
55
  readonly #emitter = new EventEmitter();
97
- readonly #subscriptions = new Map<number, SubscriptionRecord>();
98
- readonly #matchRuleRefcount = new Map<string, number>();
99
- readonly #pending = new Set<PendingCall>();
56
+ readonly #calls: CallDispatcher;
57
+ readonly #signals: SignalRegistry;
100
58
 
101
59
  #bus: RawBus | null = null;
102
60
  #state: State = 'idle';
103
61
  #closing = false;
104
- #nextSubId = 1;
105
62
 
106
63
  // Bound once so the same references can be detached from a dead connection.
107
- readonly #onMessage = (message: RawMessage): void => this.#dispatchSignal(message);
64
+ readonly #onMessage = (message: RawMessage): void => this.#signals.dispatch(message);
108
65
  readonly #onConnectionError = (cause: unknown): void =>
109
66
  this.#handleDrop(cause instanceof Error ? cause : new DisconnectedError(String(cause)));
110
67
  readonly #onConnectionEnd = (): void =>
@@ -112,7 +69,12 @@ class DbusTransportImpl implements DbusTransport {
112
69
 
113
70
  constructor(options: DbusTransportOptions) {
114
71
  this.#options = options;
115
- this.#callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
72
+ this.#calls = new CallDispatcher(options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
73
+ this.#signals = new SignalRegistry({
74
+ currentBus: () => this.#bus,
75
+ isConnected: () => this.#state === 'connected',
76
+ emitError: (error) => this.#emitter.emit('error', error),
77
+ });
116
78
  this.#reconnect = {
117
79
  enabled: options.reconnect?.enabled ?? DEFAULT_RECONNECT.enabled,
118
80
  initialDelayMs: options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs,
@@ -144,113 +106,23 @@ class DbusTransportImpl implements DbusTransport {
144
106
  this.#state = 'closed';
145
107
  const bus = this.#bus;
146
108
  this.#bus = null;
147
- this.#rejectPending(new DisconnectedError('transport closed'));
109
+ this.#calls.rejectAll(new DisconnectedError('transport closed'));
148
110
  if (bus) {
149
111
  this.#quiesce(bus);
150
112
  await bus.disconnect().catch(() => undefined);
151
113
  }
152
114
  }
153
115
 
154
- async callMethod(call: MethodCall): Promise<MethodReply> {
155
- const bus = this.#bus;
156
- if (this.#state !== 'connected' || bus === null) {
157
- throw new DisconnectedError('cannot call method: transport not connected');
158
- }
159
-
160
- const signature = call.signature ?? '';
161
- const args = call.args ?? [];
162
- const message: RawMessage = {
163
- type: messageType.methodCall,
164
- destination: call.destination,
165
- path: call.path,
166
- interface: call.interface,
167
- member: call.member,
168
- };
169
- if (signature.length > 0) {
170
- // Throws UnsupportedSignatureError / BigIntRequiredError before anything hits
171
- // the wire.
172
- message.signature = signature;
173
- message.body = encodeBody(signature, args);
174
- }
175
-
176
- const timeoutMs = call.timeoutMs ?? this.#callTimeoutMs;
177
- const pendingSet = this.#pending;
178
- return new Promise<MethodReply>((resolve, reject) => {
179
- let done = false;
180
- const pending: PendingCall = {
181
- settle: finish,
182
- reject: (error) => {
183
- finish();
184
- reject(error);
185
- },
186
- };
187
-
188
- function finish(): void {
189
- if (done) {
190
- return;
191
- }
192
- done = true;
193
- clearTimeout(timer);
194
- pendingSet.delete(pending);
195
- }
196
-
197
- const timer = setTimeout(() => {
198
- finish();
199
- reject(
200
- new TransportError(
201
- `Method call ${call.interface}.${call.member} timed out after ${timeoutMs}ms`,
202
- ),
203
- );
204
- }, timeoutMs);
205
-
206
- pendingSet.add(pending);
207
-
208
- bus.invoke(
209
- message,
210
- function reply(this: ReplyContext, error: unknown, ...body: unknown[]): void {
211
- if (done) {
212
- // Reply arrived after timeout/disconnect already settled the promise — ignore.
213
- return;
214
- }
215
- finish();
216
- if (error) {
217
- reject(error instanceof Error ? error : new TransportError(String(error)));
218
- return;
219
- }
220
- try {
221
- const replySignature = this.signature ?? '';
222
- const decoded: DbusValue[] =
223
- replySignature.length > 0 ? decodeBody(replySignature, body) : [];
224
- resolve({ signature: replySignature, body: decoded });
225
- } catch (decodeError) {
226
- reject(decodeError);
227
- }
228
- },
229
- );
230
- });
116
+ callMethod(call: MethodCall): Promise<MethodReply> {
117
+ return this.#calls.call(this.#bus, this.#state === 'connected', call);
231
118
  }
232
119
 
233
- async subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise<Subscription> {
234
- const rule = buildMatchRule(spec);
235
- const id = this.#nextSubId++;
236
- this.#subscriptions.set(id, { id, spec, listener, rule });
237
- await this.#addMatchRule(rule);
238
-
239
- let removed = false;
240
- return {
241
- unsubscribe: async (): Promise<void> => {
242
- if (removed) {
243
- return;
244
- }
245
- removed = true;
246
- this.#subscriptions.delete(id);
247
- await this.#removeMatchRule(rule);
248
- },
249
- };
120
+ subscribeSignal(spec: SignalSpec, listener: SignalListener): Promise<Subscription> {
121
+ return this.#signals.subscribe(spec, listener);
250
122
  }
251
123
 
252
124
  subscriptionCount(): number {
253
- return this.#subscriptions.size;
125
+ return this.#signals.count();
254
126
  }
255
127
 
256
128
  on(event: TransportEvent, handler: (payload?: unknown) => void): void {
@@ -300,9 +172,7 @@ class DbusTransportImpl implements DbusTransport {
300
172
  bus.connection.on('end', this.#onConnectionEnd);
301
173
 
302
174
  // Re-issue every live match rule so a reconnect resubscribes transparently.
303
- for (const rule of this.#matchRuleRefcount.keys()) {
304
- await bus.addMatch(rule);
305
- }
175
+ await this.#signals.reissueRules(bus);
306
176
 
307
177
  this.#bus = bus;
308
178
  this.#state = 'connected';
@@ -319,58 +189,6 @@ class DbusTransportImpl implements DbusTransport {
319
189
  }
320
190
  }
321
191
 
322
- #dispatchSignal(message: RawMessage): void {
323
- if (message.type !== messageType.signal) {
324
- return;
325
- }
326
- for (const record of this.#subscriptions.values()) {
327
- if (!signalMatches(record.spec, message)) {
328
- continue;
329
- }
330
- let body: DbusValue[];
331
- try {
332
- const signature = message.signature ?? '';
333
- body = signature.length > 0 ? decodeBody(signature, message.body ?? []) : [];
334
- } catch (error) {
335
- this.#emitter.emit('error', error);
336
- continue;
337
- }
338
- const event: SignalEvent = {
339
- path: message.path ?? '',
340
- interface: message.interface ?? '',
341
- member: message.member ?? '',
342
- sender: message.sender,
343
- signature: message.signature ?? '',
344
- body,
345
- };
346
- try {
347
- record.listener(event);
348
- } catch (error) {
349
- this.#emitter.emit('error', error);
350
- }
351
- }
352
- }
353
-
354
- async #addMatchRule(rule: string): Promise<void> {
355
- const current = this.#matchRuleRefcount.get(rule) ?? 0;
356
- this.#matchRuleRefcount.set(rule, current + 1);
357
- if (current === 0 && this.#state === 'connected' && this.#bus) {
358
- await this.#bus.addMatch(rule);
359
- }
360
- }
361
-
362
- async #removeMatchRule(rule: string): Promise<void> {
363
- const current = this.#matchRuleRefcount.get(rule) ?? 0;
364
- if (current <= 1) {
365
- this.#matchRuleRefcount.delete(rule);
366
- if (current === 1 && this.#state === 'connected' && this.#bus) {
367
- await this.#bus.removeMatch(rule).catch(() => undefined);
368
- }
369
- } else {
370
- this.#matchRuleRefcount.set(rule, current - 1);
371
- }
372
- }
373
-
374
192
  #detachHandlers(bus: RawBus): void {
375
193
  bus.connection.removeListener('message', this.#onMessage as (...args: unknown[]) => void);
376
194
  bus.connection.removeListener('error', this.#onConnectionError);
@@ -385,13 +203,6 @@ class DbusTransportImpl implements DbusTransport {
385
203
  bus.connection.on('error', () => undefined);
386
204
  }
387
205
 
388
- #rejectPending(cause: unknown): void {
389
- for (const pending of this.#pending) {
390
- pending.reject(cause);
391
- }
392
- this.#pending.clear();
393
- }
394
-
395
206
  #handleDrop(cause: unknown): void {
396
207
  if (this.#closing) {
397
208
  return;
@@ -404,7 +215,7 @@ class DbusTransportImpl implements DbusTransport {
404
215
  this.#quiesce(this.#bus);
405
216
  }
406
217
  this.#bus = null;
407
- this.#rejectPending(cause);
218
+ this.#calls.rejectAll(cause);
408
219
  this.#emitter.emit('disconnected', cause);
409
220
  if (this.#reconnect.enabled) {
410
221
  void this.#reconnectLoop();
@@ -1,8 +1,13 @@
1
- // The certified USB-mode catalog — schema, data, and lookups.
1
+ // The certified USB-mode catalog — schema, data, lookups, and the evidence-bundle
2
+ // ingestion seam.
2
3
  //
3
4
  // A6.1's bench CLI (`set-usb-mode`) and A6.2's `certify` tool both consume this: the
4
5
  // CLI looks up the permitted transition for a target mode, `certify` validates a
5
6
  // candidate entry against the schema before a human commits it.
7
+ //
8
+ // The ingestion seam (`./ingestion`, `./promotion-review`) is the documented path from a
9
+ // captured `certify` bundle to a reviewed catalog commit — see `docs/CATALOG-INGESTION.md`.
10
+ // It refuses a `synthetic: true` bundle for catalog promotion, by construction.
6
11
 
7
12
  export {
8
13
  CERTIFIED_CATALOG,
@@ -25,3 +30,29 @@ export {
25
30
  permittedTransitionSchema,
26
31
  type SkuDiscriminator,
27
32
  } from './catalog-schema';
33
+ export {
34
+ buildCatalogEntryCandidate,
35
+ buildClassifierFixture,
36
+ type CatalogClaim,
37
+ CLAIMABLE_CANONICAL_MODES,
38
+ type ClassifierFixture,
39
+ type EvidenceBundleView,
40
+ evidenceBundleViewSchema,
41
+ type FixtureProvenance,
42
+ type IngestionOutcome,
43
+ type IngestionRefusal,
44
+ type IngestionRefusalReason,
45
+ type IngestionRequest,
46
+ parseIngestionRequest,
47
+ } from './ingestion';
48
+ export {
49
+ type PromotionContext,
50
+ type PromotionRequest,
51
+ renderPromotionReview,
52
+ } from './promotion-review';
53
+ export {
54
+ type ParsedUsbDevice,
55
+ type ParsedUsbInterface,
56
+ parseUsbDevices,
57
+ selectUniqueDevice,
58
+ } from './usb-devices-parse';
@@ -0,0 +1,268 @@
1
+ // The ingestion seam's contract: a bundle round-trips into a schema-valid catalog entry
2
+ // and a real-shaped classifier fixture, the sha256 links the two, and a synthetic bundle
3
+ // is REFUSED for catalog promotion with a typed reason rather than silently accepted.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { classifyDevice, detectUsbMode } from '../backend/device-classifier';
7
+ import { catalogEntrySchema } from './catalog-schema';
8
+ import {
9
+ buildCatalogEntryCandidate,
10
+ buildClassifierFixture,
11
+ type IngestionRequest,
12
+ parseIngestionRequest,
13
+ } from './ingestion';
14
+ import { renderPromotionReview } from './promotion-review';
15
+ import { parseUsbDevices, selectUniqueDevice } from './usb-devices-parse';
16
+
17
+ const SHA = 'a'.repeat(64);
18
+
19
+ /** Verbatim-shaped `usb-devices` output for a QMI stick plus one unrelated hub. */
20
+ const USB_DEVICES = `
21
+ T: Bus=04 Lev=01 Prnt=01 Port=00 Cnt=01 Dev#= 2 Spd=5000 MxCh= 4
22
+ D: Ver= 3.20 Cls=09(hub ) Sub=00 Prot=03 MxPS= 9 #Cfgs= 1
23
+ P: Vendor=0bda ProdID=0411 Rev=01.01
24
+ S: Manufacturer=Generic
25
+ S: Product=USB3.2 Hub
26
+ I: If#= 0 Alt= 0 #EPs= 1 Cls=09(hub ) Sub=00 Prot=00 Driver=hub
27
+
28
+ T: Bus=04 Lev=03 Prnt=04 Port=03 Cnt=01 Dev#= 7 Spd=480 MxCh= 0
29
+ D: Ver= 2.00 Cls=00(>ifc ) Sub=00 Prot=00 MxPS=64 #Cfgs= 1
30
+ P: Vendor=2c7c ProdID=0125 Rev=03.18
31
+ S: Manufacturer=Quectel
32
+ S: Product=SYNTHETIC-BENCH-STICK
33
+ I: If#= 2 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=00 Prot=00 Driver=option
34
+ I: If#= 4 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=ff Prot=ff Driver=qmi_wwan
35
+ `;
36
+
37
+ function bundle(overrides: Record<string, unknown> = {}): Record<string, unknown> {
38
+ return {
39
+ schemaVersion: 1,
40
+ synthetic: false,
41
+ capturedAtMs: 1_760_000_000_000,
42
+ slot: 'Modem/2',
43
+ sku: {
44
+ vidPid: '2c7c:0125',
45
+ model: 'CERALIVE-SYNTHETIC-TEST-SKU',
46
+ firmwarePrefix: 'SYNTHETICFW01',
47
+ },
48
+ usb: {
49
+ usbDevices: USB_DEVICES,
50
+ udevProperties: {
51
+ ID_PATH: 'platform-xhci-hcd.0.auto-usb-0:1.4.4',
52
+ ID_VENDOR_ID: '2c7c',
53
+ ID_MODEL_ID: '0125',
54
+ INTERFACE: 'wwan0',
55
+ },
56
+ },
57
+ // Fields the real bundle carries and the ingestion VIEW deliberately ignores.
58
+ usbExtra: { lsusb: 'Device Descriptor:' },
59
+ modemManager: { mmcliKeyfile: {}, managedObjects: {}, signalWindow: [] },
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ const request = (overrides: Record<string, unknown> = {}, sha = SHA): IngestionRequest => ({
65
+ bundle: bundle(overrides),
66
+ bundleSha256: sha,
67
+ });
68
+
69
+ const TRANSITION = {
70
+ from: 'qmi',
71
+ to: 'mbim',
72
+ atCommand: 'AT+QCFG="usbnet",2',
73
+ expectedResponse: 'OK',
74
+ expectsPortDrop: true,
75
+ afterDescriptors: {
76
+ deviceClass: 0,
77
+ interfaces: [
78
+ { interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 },
79
+ { interfaceClass: 10, interfaceSubClass: 0, interfaceProtocol: 2 },
80
+ ],
81
+ },
82
+ timeline: [{ event: 'command-sent', atMs: 1 }],
83
+ };
84
+
85
+ describe('parseUsbDevices — the descriptor source a base bundle actually carries', () => {
86
+ test('parses every device, its bDeviceClass, and each interface driver', () => {
87
+ const devices = parseUsbDevices(USB_DEVICES);
88
+ expect(devices).toHaveLength(2);
89
+ const stick = devices[1];
90
+ expect(stick?.vidPid).toBe('2c7c:0125');
91
+ expect(stick?.bDeviceClass).toBe(0);
92
+ expect(stick?.product).toBe('SYNTHETIC-BENCH-STICK');
93
+ expect(stick?.interfaces).toEqual([
94
+ { interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'option' },
95
+ {
96
+ interfaceClass: 0xff,
97
+ interfaceSubClass: 0xff,
98
+ interfaceProtocol: 0xff,
99
+ driver: 'qmi_wwan',
100
+ },
101
+ ]);
102
+ });
103
+
104
+ test('a block with no P: line yields no record — identity is never invented', () => {
105
+ expect(parseUsbDevices('T: Bus=01\nI: If#= 0 Cls=ff Sub=ff Prot=ff Driver=x')).toEqual([]);
106
+ });
107
+
108
+ test('a duplicate VID:PID is AMBIGUOUS, not first-wins (the Huawei HiLink pair)', () => {
109
+ const pair = `${USB_DEVICES}\nT: Bus=01 Lev=01\nD: Cls=00\nP: Vendor=2c7c ProdID=0125 Rev=03.18\nI: If#= 0 Cls=ff Sub=ff Prot=ff Driver=qmi_wwan\n`;
110
+ const selected = selectUniqueDevice(parseUsbDevices(pair), '2c7c:0125');
111
+ expect(selected).toEqual({ ambiguousMatches: 2 });
112
+ });
113
+ });
114
+
115
+ describe('buildClassifierFixture — the real udev shape, not a hand-typed approximation', () => {
116
+ test('produces a snapshot the REAL classifier classifies correctly', () => {
117
+ const outcome = buildClassifierFixture(request());
118
+ expect(outcome.ok).toBe(true);
119
+ if (!outcome.ok) {
120
+ return;
121
+ }
122
+ const { snapshot, provenance } = outcome.value;
123
+ expect(snapshot.vendorId).toBe('2c7c');
124
+ expect(snapshot.productId).toBe('0125');
125
+ expect(snapshot.model).toBe('CERALIVE-SYNTHETIC-TEST-SKU');
126
+ expect(snapshot.firmwareRevision).toBe('SYNTHETICFW01');
127
+ expect(snapshot.physicalUid).toBe('platform-xhci-hcd.0.auto-usb-0:1.4.4');
128
+ expect(snapshot.ifname).toBe('wwan0');
129
+ // The whole point of deriving from real capture text: the fixture must survive
130
+ // the production classifier, not merely typecheck.
131
+ expect(classifyDevice(snapshot).deviceClass).toBe('mm-managed');
132
+ expect(detectUsbMode(snapshot)).toBe('qmi');
133
+ expect(provenance.bundleSha256).toBe(SHA);
134
+ expect(provenance.synthetic).toBe(false);
135
+ });
136
+
137
+ test('a SYNTHETIC bundle still yields a fixture, stamped synthetic in provenance', () => {
138
+ const outcome = buildClassifierFixture(request({ synthetic: true }));
139
+ expect(outcome.ok).toBe(true);
140
+ if (outcome.ok) {
141
+ expect(outcome.value.provenance.synthetic).toBe(true);
142
+ }
143
+ });
144
+
145
+ test('refuses a bundle with no sku (blocker B2 shape) rather than inventing one', () => {
146
+ const outcome = buildClassifierFixture(request({ sku: undefined }));
147
+ expect(outcome).toMatchObject({ ok: false, reason: 'sku-missing' });
148
+ });
149
+
150
+ test('refuses when the SKU is absent from the usb-devices capture', () => {
151
+ const outcome = buildClassifierFixture(
152
+ request({ sku: { ...(bundle().sku as object), vidPid: '1199:9071' } }),
153
+ );
154
+ expect(outcome).toMatchObject({ ok: false, reason: 'device-not-in-capture' });
155
+ });
156
+ });
157
+
158
+ describe('buildCatalogEntryCandidate — schema round-trip and sha linkage', () => {
159
+ test('a stage-1 bundle yields an entry with NO permitted transitions', () => {
160
+ const outcome = buildCatalogEntryCandidate(request(), { canonicalMode: 'qmi' });
161
+ expect(outcome.ok).toBe(true);
162
+ if (!outcome.ok) {
163
+ return;
164
+ }
165
+ expect(outcome.value.permittedTransitions).toEqual([]);
166
+ // Round-trip through the AUTHORITATIVE schema, not the builder's own view.
167
+ expect(catalogEntrySchema.parse(outcome.value)).toEqual(outcome.value);
168
+ });
169
+
170
+ test('a stage-2 bundle links the transition to THIS bundle sha256', () => {
171
+ const sha = 'b'.repeat(64);
172
+ const outcome = buildCatalogEntryCandidate(
173
+ { bundle: bundle({ transition: TRANSITION }), bundleSha256: sha },
174
+ { canonicalMode: 'qmi' },
175
+ );
176
+ expect(outcome.ok).toBe(true);
177
+ if (!outcome.ok) {
178
+ return;
179
+ }
180
+ const [transition] = outcome.value.permittedTransitions;
181
+ expect(transition?.evidenceBundleSha256).toBe(sha);
182
+ expect(transition?.expectedDescriptors).toEqual(TRANSITION.afterDescriptors);
183
+ expect(transition?.atCommand).toBe('AT+QCFG="usbnet",2');
184
+ expect(catalogEntrySchema.parse(outcome.value)).toEqual(outcome.value);
185
+ });
186
+
187
+ test('REFUSES a synthetic:true bundle for catalog promotion — typed, not silent', () => {
188
+ const outcome = buildCatalogEntryCandidate(request({ synthetic: true }), {
189
+ canonicalMode: 'qmi',
190
+ });
191
+ expect(outcome.ok).toBe(false);
192
+ if (outcome.ok) {
193
+ return;
194
+ }
195
+ expect(outcome.reason).toBe('synthetic-bundle');
196
+ expect(outcome.detail).toContain('synthetic:true');
197
+ });
198
+
199
+ test('refuses a claimed mode that contradicts the captured transition.from', () => {
200
+ const outcome = buildCatalogEntryCandidate(
201
+ { bundle: bundle({ transition: TRANSITION }), bundleSha256: SHA },
202
+ { canonicalMode: 'mbim' },
203
+ );
204
+ expect(outcome).toMatchObject({ ok: false, reason: 'transition-mode-mismatch' });
205
+ });
206
+
207
+ test('refuses a router-mode SKU that carries a transition (schema invariant)', () => {
208
+ const outcome = buildCatalogEntryCandidate(
209
+ { bundle: bundle({ transition: TRANSITION }), bundleSha256: SHA },
210
+ { canonicalMode: 'router-ethernet' },
211
+ );
212
+ // The mode cross-check fires first; either refusal is correct, neither is an accept.
213
+ expect(outcome.ok).toBe(false);
214
+ });
215
+
216
+ test('accepts a router-mode SKU with no transitions (the RB-15 shape)', () => {
217
+ const outcome = buildCatalogEntryCandidate(request(), { canonicalMode: 'router-ethernet' });
218
+ expect(outcome.ok).toBe(true);
219
+ if (outcome.ok) {
220
+ expect(outcome.value.canonicalMode).toBe('router-ethernet');
221
+ expect(outcome.value.permittedTransitions).toEqual([]);
222
+ }
223
+ });
224
+
225
+ test('refuses a malformed sha256 before reading the bundle at all', () => {
226
+ expect(parseIngestionRequest({ bundle: bundle(), bundleSha256: 'nope' })).toMatchObject({
227
+ ok: false,
228
+ reason: 'sha256-malformed',
229
+ });
230
+ });
231
+
232
+ test('refuses a bundle that fails the view schema', () => {
233
+ expect(
234
+ parseIngestionRequest({ bundle: { schemaVersion: 2 }, bundleSha256: SHA }),
235
+ ).toMatchObject({ ok: false, reason: 'bundle-malformed' });
236
+ });
237
+ });
238
+
239
+ describe('renderPromotionReview — the review artifact, including for refusals', () => {
240
+ test('renders the entry, the fixture, and a checklist on success', () => {
241
+ const req = request();
242
+ const comment = renderPromotionReview({
243
+ context: { runbook: 'RB-11', evidencePath: 'test-results/modem-phase-b/08/x/bundle.json' },
244
+ entry: buildCatalogEntryCandidate(req, { canonicalMode: 'qmi' }),
245
+ fixture: buildClassifierFixture(req),
246
+ });
247
+ expect(comment).toContain('Proposed `certified-catalog.json` entry');
248
+ expect(comment).toContain('Proposed classifier fixture');
249
+ expect(comment).toContain('Reviewer checklist');
250
+ expect(comment).toContain('RB-11');
251
+ expect(comment).toContain('**This comment promotes');
252
+ });
253
+
254
+ test('a refused promotion renders the refusal and NO checklist', () => {
255
+ const req = request({ synthetic: true });
256
+ const comment = renderPromotionReview({
257
+ context: { runbook: 'RB-11', evidencePath: 'x.json' },
258
+ entry: buildCatalogEntryCandidate(req, { canonicalMode: 'qmi' }),
259
+ fixture: buildClassifierFixture(req),
260
+ });
261
+ expect(comment).toContain('Catalog entry — REFUSED');
262
+ expect(comment).toContain('`synthetic-bundle`');
263
+ expect(comment).toContain('### No checklist');
264
+ expect(comment).not.toContain('Reviewer checklist');
265
+ // The fixture half still renders — synthetic fixtures are legitimate test data.
266
+ expect(comment).toContain('Derived from a **synthetic** bundle');
267
+ });
268
+ });