@forgeax/engine-rhi-debug 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +39 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/readback-matrix-fixture.d.ts +32 -0
  4. package/dist/__tests__/readback-matrix-fixture.d.ts.map +1 -0
  5. package/dist/frame-model.d.ts +84 -52
  6. package/dist/frame-model.d.ts.map +1 -1
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.mjs +4651 -46
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/recorder/device.d.ts.map +1 -1
  12. package/dist/recorder/encoder.d.ts.map +1 -1
  13. package/dist/replay/device-request.d.ts +8 -0
  14. package/dist/replay/device-request.d.ts.map +1 -0
  15. package/dist/replay/readback.d.ts +7 -0
  16. package/dist/replay/readback.d.ts.map +1 -1
  17. package/dist/replay/session.d.ts +17 -0
  18. package/dist/replay/session.d.ts.map +1 -1
  19. package/dist/texel-decode.d.ts +5 -4
  20. package/dist/texel-decode.d.ts.map +1 -1
  21. package/dist/types.d.ts +24 -6
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +7 -7
  24. package/src/__tests__/consumer-inventory.unit.test.ts +49 -1
  25. package/src/__tests__/e2e.browser.test.ts +6 -1
  26. package/src/__tests__/frame-model-parity.test-d.ts +13 -1
  27. package/src/__tests__/frame-model-parity.unit.test.ts +262 -15
  28. package/src/__tests__/guard-gates.test.ts +176 -1
  29. package/src/__tests__/public-surface.integration.test.ts +8 -0
  30. package/src/__tests__/readback-format-matrix.dawn.test.ts +226 -3
  31. package/src/__tests__/readback-format-matrix.unit.test.ts +25 -1
  32. package/src/__tests__/readback-matrix-fixture.ts +20 -0
  33. package/src/__tests__/replay-fail-fast.unit.test.ts +12 -0
  34. package/src/__tests__/replay-session.dawn.test.ts +3 -0
  35. package/src/__tests__/replay-session.test-d.ts +7 -1
  36. package/src/__tests__/replay-session.unit.test.ts +16 -0
  37. package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +2 -0
  38. package/src/__tests__/tape-index.unit.test.ts +3 -0
  39. package/src/__tests__/tree-shake.unit.test.ts +24 -0
  40. package/src/frame-model.ts +381 -83
  41. package/src/index.ts +17 -10
  42. package/src/recorder/device.ts +20 -2
  43. package/src/recorder/encoder.ts +22 -5
  44. package/src/replay/device-request.ts +38 -0
  45. package/src/replay/readback.ts +106 -18
  46. package/src/replay/session.ts +47 -2
  47. package/src/texel-decode.ts +37 -3
  48. package/src/types.ts +30 -6
@@ -1,8 +1,9 @@
1
1
  import type { RhiInstance } from '@forgeax/engine-rhi';
2
2
  import { describe, expect, it } from 'vitest';
3
- import type { BootstrapResource, Tape } from '../protocol/types';
3
+ import type { BootstrapResource, Tape, TapeBlob } from '../protocol/types';
4
4
  import type { ReplayBackend } from '../replay/session';
5
5
  import { openReplay } from '../replay/session';
6
+ import { READBACK_MATRIX_CASES } from './readback-matrix-fixture';
6
7
 
7
8
  interface DawnPack {
8
9
  readonly rhi: RhiInstance;
@@ -37,7 +38,7 @@ function colorResource(): BootstrapResource {
37
38
 
38
39
  function depthResource(
39
40
  handleId: string,
40
- format: 'depth24plus' | 'depth24plus-stencil8',
41
+ format: 'depth24plus' | 'depth24plus-stencil8' | 'depth32float',
41
42
  ): BootstrapResource {
42
43
  return {
43
44
  handleId,
@@ -58,7 +59,47 @@ function depthResource(
58
59
  };
59
60
  }
60
61
 
62
+ function matrixTextureResource(): BootstrapResource {
63
+ return {
64
+ handleId: 'texture:matrix',
65
+ kind: 'texture',
66
+ create: {
67
+ kind: 'createTexture',
68
+ handleId: 'texture:matrix',
69
+ desc: {
70
+ size: { width: 2, height: 2, depthOrArrayLayers: 12 },
71
+ format: 'rgba8unorm',
72
+ dimension: '2d',
73
+ mipLevelCount: 2,
74
+ sampleCount: 1,
75
+ usage: 0x1f,
76
+ },
77
+ },
78
+ initialData: [{ hash: 'matrix-texture', byteOffset: 0, byteLength: 240 }],
79
+ };
80
+ }
81
+
82
+ function matrixBlob(): TapeBlob {
83
+ return {
84
+ hash: 'matrix-texture',
85
+ bytes: Uint8Array.from({ length: 240 }, (_, index) => index),
86
+ compression: 'none',
87
+ };
88
+ }
89
+
61
90
  describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
91
+ it('enumerates the canonical descriptor and subresource matrix', () => {
92
+ expect(READBACK_MATRIX_CASES).toEqual([
93
+ { format: 'rgba8unorm', dimension: '2d', aspect: 'all' },
94
+ { format: 'depth32float', dimension: '2d', aspect: 'depth-only' },
95
+ { format: 'depth24plus', dimension: '2d', aspect: 'depth-only' },
96
+ { format: 'depth24plus-stencil8', dimension: '2d', aspect: 'stencil-only' },
97
+ { format: 'rgba8unorm', dimension: '2d-array', aspect: 'all' },
98
+ { format: 'rgba8unorm', dimension: 'cube', aspect: 'all' },
99
+ { format: 'rgba8unorm', dimension: 'cube-array', aspect: 'all' },
100
+ ]);
101
+ });
102
+
62
103
  it('reads a color subresource on a fresh Dawn device', async () => {
63
104
  const pack = await loadDawn();
64
105
  const adapter = await pack.rhi.requestAdapter();
@@ -88,6 +129,164 @@ describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
88
129
  expect(pixels.ok).toBe(true);
89
130
  if (!pixels.ok) throw new Error(pixels.error.hint);
90
131
  expect(pixels.value.bytes.byteLength).toBe(4 * 4 * 4);
132
+ expect(pixels.value.provenance).toEqual({
133
+ generation: replay.value.generation,
134
+ resourceId: 'texture:color',
135
+ subresource: { mipLevel: 0, arrayLayer: 0, aspect: 'all' },
136
+ });
137
+ });
138
+
139
+ it('reads a known buffer range through the replay owner', async () => {
140
+ const pack = await loadDawn();
141
+ const adapter = await pack.rhi.requestAdapter();
142
+ expect(adapter.ok).toBe(true);
143
+ if (!adapter.ok) throw new Error(`Dawn admission failed: ${adapter.error.code}`);
144
+ const device = await adapter.value.requestDevice();
145
+ expect(device.ok).toBe(true);
146
+ if (!device.ok) throw new Error(`Dawn device admission failed: ${device.error.code}`);
147
+ const replay = await openReplay(
148
+ {
149
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 0, blobCount: 1 },
150
+ bootstrap: [
151
+ {
152
+ handleId: 'buffer:known',
153
+ kind: 'buffer',
154
+ create: {
155
+ kind: 'createBuffer',
156
+ handleId: 'buffer:known',
157
+ desc: { size: 8, usage: 0x0c },
158
+ },
159
+ initialData: [{ hash: 'buffer-known', byteOffset: 0, byteLength: 8 }],
160
+ },
161
+ ],
162
+ events: [],
163
+ blobs: [
164
+ {
165
+ hash: 'buffer-known',
166
+ bytes: Uint8Array.from([0, 1, 2, 3, 4, 5, 6, 7]),
167
+ compression: 'none',
168
+ },
169
+ ],
170
+ },
171
+ { device: device.value, createShaderModule: pack.createShaderModule },
172
+ );
173
+ expect(replay.ok).toBe(true);
174
+ if (!replay.ok) throw new Error(replay.error.hint);
175
+ const result = await replay.value.readResource('buffer:known', { offset: 2, size: 4 });
176
+ expect(result.ok).toBe(true);
177
+ if (!result.ok) throw new Error(result.error.hint);
178
+ expect([...result.value.bytes]).toEqual([2, 3, 4, 5]);
179
+ expect(result.value.provenance.subresource).toEqual({ offset: 2, size: 4 });
180
+ await replay.value.dispose();
181
+ });
182
+
183
+ it('reads array, cube, cube-array, and view-local mip/layer subresources', async () => {
184
+ const pack = await loadDawn();
185
+ const adapter = await pack.rhi.requestAdapter();
186
+ expect(adapter.ok).toBe(true);
187
+ if (!adapter.ok) throw new Error(`Dawn admission failed: ${adapter.error.code}`);
188
+ const device = await adapter.value.requestDevice();
189
+ expect(device.ok).toBe(true);
190
+ if (!device.ok) throw new Error(`Dawn device admission failed: ${device.error.code}`);
191
+ const view = (handleId: string, dimension: string, baseArrayLayer: number, count: number) => ({
192
+ handleId,
193
+ kind: 'texture-view' as const,
194
+ create: {
195
+ kind: 'createTextureView',
196
+ sourceHandleId: 'texture:matrix',
197
+ resultHandleId: handleId,
198
+ desc: {
199
+ dimension,
200
+ baseMipLevel: 1,
201
+ mipLevelCount: 1,
202
+ baseArrayLayer,
203
+ arrayLayerCount: count,
204
+ },
205
+ },
206
+ initialData: [],
207
+ });
208
+ const replay = await openReplay(
209
+ {
210
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 0, blobCount: 1 },
211
+ bootstrap: [
212
+ matrixTextureResource(),
213
+ view('view:array', '2d-array', 4, 2),
214
+ view('view:cube', 'cube', 0, 6),
215
+ view('view:cube-array', 'cube-array', 0, 12),
216
+ ],
217
+ events: [],
218
+ blobs: [matrixBlob()],
219
+ },
220
+ { device: device.value, createShaderModule: pack.createShaderModule },
221
+ );
222
+ expect(replay.ok).toBe(true);
223
+ if (!replay.ok) throw new Error(replay.error.hint);
224
+ const arrayResult = await replay.value.readResource('view:array', {
225
+ mipLevel: 0,
226
+ arrayLayer: 0,
227
+ aspect: 'all',
228
+ });
229
+ expect(arrayResult.ok).toBe(true);
230
+ if (!arrayResult.ok) throw new Error(arrayResult.error.hint);
231
+ expect(arrayResult.value.width).toBe(1);
232
+ expect(arrayResult.value.height).toBe(1);
233
+ expect([...arrayResult.value.bytes]).toEqual([96, 97, 98, 99]);
234
+ const cubeResult = await replay.value.readResource('view:cube', {
235
+ mipLevel: 0,
236
+ arrayLayer: 5,
237
+ aspect: 'all',
238
+ });
239
+ expect(cubeResult.ok).toBe(true);
240
+ if (!cubeResult.ok) throw new Error(cubeResult.error.hint);
241
+ expect(cubeResult.value.provenance.subresource).toEqual({
242
+ mipLevel: 0,
243
+ arrayLayer: 5,
244
+ aspect: 'all',
245
+ });
246
+ const cubeArrayResult = await replay.value.readResource('view:cube-array', {
247
+ mipLevel: 0,
248
+ arrayLayer: 11,
249
+ aspect: 'all',
250
+ });
251
+ expect(cubeArrayResult.ok).toBe(true);
252
+ if (!cubeArrayResult.ok) throw new Error(cubeArrayResult.error.hint);
253
+ expect([...cubeArrayResult.value.bytes]).toEqual([236, 237, 238, 239]);
254
+ await replay.value.dispose();
255
+ });
256
+
257
+ it('reads depth32float through the direct depth aspect path', async () => {
258
+ const pack = await loadDawn();
259
+ const adapter = await pack.rhi.requestAdapter();
260
+ expect(adapter.ok).toBe(true);
261
+ if (!adapter.ok) throw new Error(`Dawn admission failed: ${adapter.error.code}`);
262
+ const device = await adapter.value.requestDevice();
263
+ expect(device.ok).toBe(true);
264
+ if (!device.ok) throw new Error(`Dawn device admission failed: ${device.error.code}`);
265
+ const replay = await openReplay(
266
+ {
267
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 0, blobCount: 0 },
268
+ bootstrap: [depthResource('texture:depth32', 'depth32float')],
269
+ events: [],
270
+ blobs: [],
271
+ },
272
+ { device: device.value, createShaderModule: pack.createShaderModule },
273
+ );
274
+ expect(replay.ok).toBe(true);
275
+ if (!replay.ok) throw new Error(replay.error.hint);
276
+ const result = await replay.value.readResource('texture:depth32', {
277
+ mipLevel: 0,
278
+ arrayLayer: 0,
279
+ aspect: 'depth-only',
280
+ });
281
+ expect(result.ok).toBe(true);
282
+ if (!result.ok) throw new Error(result.error.hint);
283
+ expect(result.value.bytes.byteLength).toBe(4 * 4 * 4);
284
+ expect(result.value.provenance.subresource).toEqual({
285
+ mipLevel: 0,
286
+ arrayLayer: 0,
287
+ aspect: 'depth-only',
288
+ });
289
+ await replay.value.dispose();
91
290
  });
92
291
 
93
292
  it('reads depth24plus through the replay-owned depth-to-color blit', async () => {
@@ -119,10 +318,16 @@ describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
119
318
  if (!pixels.ok) throw new Error(pixels.error.hint);
120
319
  expect(pixels.value.bytes.byteLength).toBe(4 * 4 * 4);
121
320
  expect([...new Float32Array(pixels.value.bytes.buffer)]).toHaveLength(16);
321
+ expect(pixels.value.provenance.resourceId).toBe('texture:depth24plus');
322
+ expect(pixels.value.provenance.subresource).toEqual({
323
+ mipLevel: 0,
324
+ arrayLayer: 0,
325
+ aspect: 'depth-only',
326
+ });
122
327
  await replay.value.dispose();
123
328
  });
124
329
 
125
- it('reads the explicit stencil aspect on depth24plus-stencil8', async () => {
330
+ it('reads both explicit planes on depth24plus-stencil8', async () => {
126
331
  const pack = await loadDawn();
127
332
  const adapter = await pack.rhi.requestAdapter();
128
333
  expect(adapter.ok).toBe(true);
@@ -142,6 +347,19 @@ describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
142
347
  );
143
348
  expect(replay.ok).toBe(true);
144
349
  if (!replay.ok) throw new Error(replay.error.hint);
350
+ const depth = await replay.value.readResource('texture:stencil', {
351
+ mipLevel: 0,
352
+ arrayLayer: 0,
353
+ aspect: 'depth-only',
354
+ });
355
+ expect(depth.ok).toBe(true);
356
+ if (!depth.ok) throw new Error(depth.error.hint);
357
+ expect(depth.value.bytes.byteLength).toBe(4 * 4 * 4);
358
+ expect(depth.value.provenance.subresource).toEqual({
359
+ mipLevel: 0,
360
+ arrayLayer: 0,
361
+ aspect: 'depth-only',
362
+ });
145
363
  const pixels = await replay.value.readResource('texture:stencil', {
146
364
  mipLevel: 0,
147
365
  arrayLayer: 0,
@@ -150,6 +368,11 @@ describe.skipIf(SKIP_DAWN)('Dawn replay-owned readback matrix', () => {
150
368
  expect(pixels.ok).toBe(true);
151
369
  if (!pixels.ok) throw new Error(pixels.error.hint);
152
370
  expect(pixels.value.bytes.byteLength).toBe(4 * 4);
371
+ expect(pixels.value.provenance.subresource).toEqual({
372
+ mipLevel: 0,
373
+ arrayLayer: 0,
374
+ aspect: 'stencil-only',
375
+ });
153
376
  await replay.value.dispose();
154
377
  });
155
378
  });
@@ -1,6 +1,7 @@
1
1
  import { createShaderModule, rhi } from '@forgeax/engine-rhi-null';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import type { BootstrapResource, Tape } from '../protocol/types';
4
+ import type { ReplayReadbackResult } from '../replay/readback';
4
5
  import type { TextureSubresource } from '../replay/session';
5
6
  import { openReplay } from '../replay/session';
6
7
  import { getTextureReadbackPlan } from '../replay/texture-format';
@@ -49,10 +50,14 @@ async function nullBackend() {
49
50
 
50
51
  describe('Replay readback format matrix', () => {
51
52
  it.each([
53
+ ['bgra8unorm', '2d'],
54
+ ['depth24plus-stencil8', '2d'],
52
55
  ['rgba8unorm', '2d'],
56
+ ['rgba8unorm-srgb', '2d'],
57
+ ['rg16float', '2d'],
58
+ ['rgba16float', '2d'],
53
59
  ['depth32float', '2d'],
54
60
  ['depth24plus', '2d'],
55
- ['depth24plus-stencil8', '2d-array'],
56
61
  ['rgba8unorm', 'cube'],
57
62
  ])('keeps %s/%s in the supported matrix', (format, dimension) => {
58
63
  const plan = getTextureReadbackPlan({ format, dimension });
@@ -93,4 +98,23 @@ describe('Replay readback format matrix', () => {
93
98
  };
94
99
  expect(subresource).toEqual({ mipLevel: 1, arrayLayer: 2, aspect: 'stencil-only' });
95
100
  });
101
+
102
+ it('requires readback results to preserve resource and subresource provenance', () => {
103
+ const provenance: ReplayReadbackResult['provenance'] = {
104
+ generation: 1,
105
+ resourceId: 'texture:color',
106
+ subresource: {
107
+ mipLevel: 0,
108
+ arrayLayer: 0,
109
+ aspect: 'all',
110
+ },
111
+ };
112
+ expect(provenance.resourceId).toBe('texture:color');
113
+ expect(provenance.generation).toBeGreaterThanOrEqual(0);
114
+ expect(provenance.subresource).toEqual({
115
+ mipLevel: 0,
116
+ arrayLayer: 0,
117
+ aspect: 'all',
118
+ });
119
+ });
96
120
  });
@@ -0,0 +1,20 @@
1
+ import type { BootstrapResource, Tape } from '../protocol/types';
2
+
3
+ export const READBACK_MATRIX_CASES = [
4
+ { format: 'rgba8unorm', dimension: '2d', aspect: 'all' },
5
+ { format: 'depth32float', dimension: '2d', aspect: 'depth-only' },
6
+ { format: 'depth24plus', dimension: '2d', aspect: 'depth-only' },
7
+ { format: 'depth24plus-stencil8', dimension: '2d', aspect: 'stencil-only' },
8
+ { format: 'rgba8unorm', dimension: '2d-array', aspect: 'all' },
9
+ { format: 'rgba8unorm', dimension: 'cube', aspect: 'all' },
10
+ { format: 'rgba8unorm', dimension: 'cube-array', aspect: 'all' },
11
+ ] as const;
12
+
13
+ export function makeReadbackMatrixTape(resources: readonly BootstrapResource[]): Tape {
14
+ return {
15
+ header: { formatVersion: 7, rhiCaps: {}, eventCount: 0, blobCount: 0 },
16
+ bootstrap: resources,
17
+ events: [],
18
+ blobs: [],
19
+ };
20
+ }
@@ -140,4 +140,16 @@ describe('ReplaySession fail-closed executor', () => {
140
140
  expect(inspection.error.detail?.stage).toBe('write');
141
141
  }
142
142
  });
143
+
144
+ it('rejects stale generation reads after a new inspection reset', async () => {
145
+ const result = await openReplay(tape(), { device: await device(), createShaderModule });
146
+ expect(result.ok).toBe(true);
147
+ if (!result.ok) return;
148
+ const first = await result.value.inspectWork(0);
149
+ expect(first.ok).toBe(true);
150
+ const generation = result.value.generation;
151
+ expect(generation).toBeGreaterThan(0);
152
+ expect((await result.value.dispose()).ok).toBe(true);
153
+ expect((await result.value.dispose()).ok).toBe(true);
154
+ });
143
155
  });
@@ -171,6 +171,9 @@ describe.skipIf(SKIP_DAWN)('ReplaySession Dawn contract', () => {
171
171
  if (!baseline.ok) throw new Error(baseline.error.hint);
172
172
  expect(baseline.value.attachment?.bytes.byteLength).toBe(WIDTH * HEIGHT * 4);
173
173
  expect(baseline.value.attachment?.bytes.slice(0, 4)).toEqual(new Uint8Array([23, 23, 23, 23]));
174
+ expect(baseline.value.attachment?.provenance.resourceId).toBe('texture-view:rt');
175
+ expect(baseline.value.attachment?.provenance.selectedWorkIndex).toBe(0);
176
+ expect(baseline.value.attachment?.provenance.subresource).toBeNull();
174
177
 
175
178
  const second = await openReplay(tape, {
176
179
  device: await freshDevice(pack),
@@ -1,6 +1,6 @@
1
1
  import type { RhiDevice } from '@forgeax/engine-rhi';
2
2
  import { expectTypeOf } from 'vitest';
3
- import type { ReplayBackend, ReplaySession } from '../replay/session';
3
+ import type { ReplayBackend, ReplaySession, WorkInspection } from '../replay/session';
4
4
 
5
5
  declare const device: RhiDevice;
6
6
  declare const session: ReplaySession;
@@ -15,6 +15,12 @@ const backend: ReplayBackend = {
15
15
  expectTypeOf(backend.createShaderModule).toBeFunction();
16
16
  expectTypeOf(session.inspectWork(0)).resolves.toHaveProperty('ok');
17
17
  expectTypeOf(session.dispose()).resolves.toHaveProperty('ok');
18
+ expectTypeOf<WorkInspection['pipeline']>().not.toBeNever();
19
+ expectTypeOf<WorkInspection['bindings']>().toMatchTypeOf<readonly unknown[] | undefined>();
20
+ expectTypeOf<WorkInspection['vertexBuffers']>().toMatchTypeOf<readonly unknown[] | undefined>();
21
+ expectTypeOf<WorkInspection['indexBuffer']>().not.toBeNever();
22
+ expectTypeOf<WorkInspection['shaders']>().toMatchTypeOf<readonly unknown[] | undefined>();
23
+ expectTypeOf<WorkInspection['resourceIds']>().toEqualTypeOf<readonly string[] | undefined>();
18
24
 
19
25
  // @ts-expect-error ReplayBackend must not be constructible without shader creation.
20
26
  const missingFactory: ReplayBackend = { device };
@@ -116,6 +116,22 @@ describe('ReplaySession', () => {
116
116
  expect('commitThroughDraw' in result.value).toBe(false);
117
117
  });
118
118
 
119
+ it('returns selected-work facts for every inspection selector', async () => {
120
+ const result = await openReplay(makeWorkTape(), await makeBackend());
121
+ expect(result.ok).toBe(true);
122
+ if (!result.ok) return;
123
+
124
+ const inspection = await result.value.inspectWork(0, ['bindings', 'pipeline']);
125
+ expect(inspection.ok).toBe(true);
126
+ if (!inspection.ok) return;
127
+ expect(inspection.value.pipeline).toBeDefined();
128
+ expect(inspection.value.bindings).toBeDefined();
129
+ expect(inspection.value.vertexBuffers).toBeDefined();
130
+ expect(inspection.value.indexBuffer).toBeDefined();
131
+ expect(inspection.value.shaders).toBeDefined();
132
+ expect(inspection.value.resourceIds).toBeDefined();
133
+ });
134
+
119
135
  it('resets internally when selecting an earlier work and rejects invalid positions', async () => {
120
136
  const tape = makeWorkTape();
121
137
  const withSecondWork: Tape = {
@@ -170,6 +170,8 @@ describe.skipIf(SKIP_DAWN)('RHI debug v7 fresh-device evidence', () => {
170
170
  const replayPixels = inspection.value.attachment?.bytes;
171
171
  expect(replayPixels).toBeDefined();
172
172
  if (replayPixels === undefined) throw new Error('fresh replay did not return attachment bytes');
173
+ expect(inspection.value.attachment?.provenance.selectedWorkIndex).toBe(0);
174
+ expect(inspection.value.attachment?.provenance.resourceId).toBeDefined();
173
175
  expect(normalizedPixelDelta(baseline, replayPixels)).toBeLessThanOrEqual(0.01);
174
176
  expect((await session.value.dispose()).ok).toBe(true);
175
177
  }, 60_000);
@@ -43,6 +43,9 @@ describe('TapeIndex', () => {
43
43
  [2, 5, 1],
44
44
  ]);
45
45
  expect(index.passes).toHaveLength(2);
46
+ expect(index.eventKinds).toContain('dispatchWorkgroups');
47
+ expect(index.passIndexByEvent[1]).toBe(0);
48
+ expect(index.passIndexByEvent[5]).toBe(1);
46
49
  });
47
50
 
48
51
  it('does not invent work for an empty pass', () => {
@@ -108,6 +108,30 @@ describe('browser entry isolation (AC-16)', () => {
108
108
  });
109
109
  });
110
110
 
111
+ describe('retained core dependency boundary (OOS-5)', () => {
112
+ it('does not include viewer or host dependency identifiers in core production sources', () => {
113
+ const sourceFiles = [
114
+ 'index.ts',
115
+ 'frame-model.ts',
116
+ 'protocol/codec.ts',
117
+ 'replay/session.ts',
118
+ ].map((file) => path.resolve(__dirname, '..', file));
119
+ const forbidden = [
120
+ '@codemirror/',
121
+ 'dockview',
122
+ '@forgeax/engine-naga',
123
+ "from 'react'",
124
+ 'node:fs',
125
+ 'node:path',
126
+ ];
127
+ const hits = sourceFiles.flatMap((file) => {
128
+ const source = fs.readFileSync(file, 'utf8');
129
+ return forbidden.filter((token) => source.includes(token)).map((token) => `${file}:${token}`);
130
+ });
131
+ expect(hits).toEqual([]);
132
+ });
133
+ });
134
+
111
135
  describe('v7 cold model path', () => {
112
136
  it('keeps protocol and model sources free of host-only imports', () => {
113
137
  const sourcePaths = [