@nonstrict/recordkit 0.87.1 → 0.97.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.
Files changed (54) hide show
  1. package/bin/README.md +1 -1
  2. package/bin/recordkit-rpc +0 -0
  3. package/out/Errors.d.ts +91 -0
  4. package/out/Errors.js +63 -0
  5. package/out/Errors.js.map +1 -0
  6. package/out/InputEvents.d.ts +661 -0
  7. package/out/InputEvents.js +8 -0
  8. package/out/InputEvents.js.map +1 -0
  9. package/out/IpcRecordKit.js +13 -0
  10. package/out/IpcRecordKit.js.map +1 -1
  11. package/out/NonstrictRPC.d.ts +9 -0
  12. package/out/NonstrictRPC.js +27 -4
  13. package/out/NonstrictRPC.js.map +1 -1
  14. package/out/RecordKit.d.ts +267 -5
  15. package/out/RecordKit.js +243 -3
  16. package/out/RecordKit.js.map +1 -1
  17. package/out/Recorder.d.ts +451 -53
  18. package/out/Recorder.js +80 -2
  19. package/out/Recorder.js.map +1 -1
  20. package/out/RecordingMetadata.d.ts +96 -0
  21. package/out/RecordingMetadata.js +12 -0
  22. package/out/RecordingMetadata.js.map +1 -0
  23. package/out/WebAudioUtils.d.ts +35 -0
  24. package/out/WebAudioUtils.js +37 -0
  25. package/out/WebAudioUtils.js.map +1 -1
  26. package/out/WindowLevels.d.ts +46 -0
  27. package/out/WindowLevels.js +41 -0
  28. package/out/WindowLevels.js.map +1 -0
  29. package/out/browser.d.ts +8 -1
  30. package/out/browser.js +3 -1
  31. package/out/browser.js.map +1 -1
  32. package/out/index.cjs +603 -9
  33. package/out/index.cjs.map +1 -1
  34. package/out/index.d.ts +8 -0
  35. package/out/index.js +3 -0
  36. package/out/index.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/Errors.test.ts +38 -0
  39. package/src/Errors.ts +151 -0
  40. package/src/InputEvents.ts +695 -0
  41. package/src/IpcRecordKit.ts +12 -0
  42. package/src/NonstrictRPC.test.ts +24 -1
  43. package/src/NonstrictRPC.ts +29 -5
  44. package/src/RecordKit.ts +347 -11
  45. package/src/Recorder.schema.test.ts +167 -0
  46. package/src/Recorder.ts +558 -80
  47. package/src/RecordingMetadata.ts +125 -0
  48. package/src/WebAudioUtils.test.ts +78 -0
  49. package/src/WebAudioUtils.ts +57 -1
  50. package/src/WindowLevels.test.ts +34 -0
  51. package/src/WindowLevels.ts +47 -0
  52. package/src/browser.ts +12 -2
  53. package/src/index.ts +8 -0
  54. package/src/__snapshots__/NonstrictRPC.test.ts.snap +0 -24
@@ -0,0 +1,167 @@
1
+ import { Recorder } from './Recorder.js';
2
+ import type { AudioStreamBuffer, RecorderSchemaItem } from './Recorder.js';
3
+ import type { Display, Microphone, Window } from './RecordKit.js';
4
+ import type { NSRPC } from './NonstrictRPC.js';
5
+
6
+ // Pins the TypeScript -> RPC wire mapping done by Recorder.newInstance: device objects are
7
+ // normalized to ids, callbacks are replaced by registered-closure tokens, and optional fields
8
+ // (defaults) are passed through verbatim or omitted — never silently injected. This is the layer
9
+ // where parity with the Swift schema can drift, so it is locked down here without spawning the RPC.
10
+ interface RegisteredClosure {
11
+ handler: (params: Record<string, unknown>) => Record<string, unknown> | void;
12
+ prefix: string;
13
+ }
14
+
15
+ function mockRpc() {
16
+ const calls: { initialize?: any; closures: RegisteredClosure[] } = { closures: [] };
17
+ const rpc = {
18
+ async initialize(args: any) { calls.initialize = args; },
19
+ registerClosure(options: RegisteredClosure) { calls.closures.push(options); return `closure:${options.prefix}`; },
20
+ async perform() { return undefined; },
21
+ async manualRelease() { },
22
+ } as unknown as NSRPC;
23
+ return { rpc, calls };
24
+ }
25
+
26
+ async function wire(items: RecorderSchemaItem[]) {
27
+ const { rpc, calls } = mockRpc();
28
+ await Recorder.newInstance(rpc, { items });
29
+ return { item: calls.initialize.params.schema.items[0], calls };
30
+ }
31
+
32
+ // Fixtures are fully-typed literals (no casts), so this suite also locks down the public schema
33
+ // types themselves: a field disappearing from the types fails compilation here.
34
+ const MICROPHONE: Microphone = { id: 'mic-7', isVirtual: false, name: 'X', model_id: 'model', manufacturer: 'Acme', availability: 'available' };
35
+ const DISPLAY: Display = { id: 5, symbolName: 'display', frame: { x: 0, y: 0, width: 1920, height: 1080 }, isMain: true, availability: 'available' };
36
+ const WINDOW: Window = { id: 42, frame: { x: 0, y: 0, width: 800, height: 600 }, level: 0 };
37
+
38
+ describe('Recorder schema serialization (TS -> RPC wire)', () => {
39
+ it('passes microphone echoCancellation / audioDelay / filename through verbatim', async () => {
40
+ const { item } = await wire([
41
+ { type: 'microphone', microphone: 'mic-1', echoCancellation: 'aggressive', audioDelay: 0.3, filename: 'm.m4a' },
42
+ ]);
43
+ expect(item).toMatchObject({ type: 'microphone', microphone: 'mic-1', echoCancellation: 'aggressive', audioDelay: 0.3, filename: 'm.m4a' });
44
+ });
45
+
46
+ it('normalizes a Microphone object to its id', async () => {
47
+ const { item } = await wire([
48
+ { type: 'microphone', microphone: MICROPHONE },
49
+ ]);
50
+ expect(item.microphone).toBe('mic-7');
51
+ });
52
+
53
+ it('keeps applicationAudio backend and applicationID on the wire', async () => {
54
+ const { item } = await wire([
55
+ { type: 'applicationAudio', applicationID: 64149, backend: 'screenCaptureKit', filename: 'a.m4a' },
56
+ ]);
57
+ expect(item).toMatchObject({ type: 'applicationAudio', applicationID: 64149, backend: 'screenCaptureKit', filename: 'a.m4a' });
58
+ });
59
+
60
+ it('normalizes a Display object and replaces a segment callback with a closure token', async () => {
61
+ const { item, calls } = await wire([
62
+ { type: 'display', display: DISPLAY, output: 'segmented', segmentCallback: () => { } },
63
+ ]);
64
+ expect(item.display).toBe(5);
65
+ expect(typeof item.segmentCallback).toBe('string'); // function replaced by a registered-closure token
66
+ expect(calls.closures.some((c) => c.prefix === 'Display.onSegment')).toBe(true);
67
+ });
68
+
69
+ it('normalizes a desktopIndependentWindow Window object and replaces its segment callback', async () => {
70
+ const { item, calls } = await wire([
71
+ { type: 'desktopIndependentWindow', window: WINDOW, output: 'segmented', segmentCallback: () => { } },
72
+ ]);
73
+ expect(item.window).toBe(42);
74
+ expect(typeof item.segmentCallback).toBe('string');
75
+ expect(calls.closures.some((c) => c.prefix === 'DesktopIndependentWindow.onSegment')).toBe(true);
76
+ });
77
+
78
+ it('does not inject defaults for omitted optional fields (omitted = Swift default)', async () => {
79
+ const { item } = await wire([{ type: 'microphone', microphone: 'mic-1' }]);
80
+ expect('echoCancellation' in item).toBe(false);
81
+ expect('audioDelay' in item).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe('Recorder inputEventsOutput wiring (TS -> RPC wire)', () => {
86
+ async function wireSegmentedInputEvents(item: RecorderSchemaItem, expectedPrefix: string) {
87
+ const { item: wired, calls } = await wire([item]);
88
+ expect(typeof wired.inputEventsOutput.segmentCallback).toBe('string'); // function replaced by a registered-closure token
89
+ const closure = calls.closures.find((c) => c.prefix === expectedPrefix);
90
+ expect(closure).toBeDefined();
91
+ return closure!;
92
+ }
93
+
94
+ it('replaces the display inputEventsOutput segment callback and forwards segment paths', async () => {
95
+ const received: string[] = [];
96
+ const closure = await wireSegmentedInputEvents(
97
+ { type: 'display', display: 5, mouse_events: true, inputEventsOutput: { output: 'segmented', segmentCallback: (path) => received.push(path) } },
98
+ 'Display.onInputEventsSegment',
99
+ );
100
+ closure.handler({ path: '/tmp/My Recordings/display-events 1.json' });
101
+ expect(received).toEqual(['/tmp/My Recordings/display-events 1.json']);
102
+ });
103
+
104
+ it('replaces the windowBasedCrop inputEventsOutput segment callback', async () => {
105
+ await wireSegmentedInputEvents(
106
+ { type: 'windowBasedCrop', window: 42, mouse_events: true, inputEventsOutput: { output: 'segmented', segmentCallback: () => { } } },
107
+ 'WindowBasedCrop.onInputEventsSegment',
108
+ );
109
+ });
110
+
111
+ it('replaces the desktopIndependentWindow inputEventsOutput segment callback', async () => {
112
+ await wireSegmentedInputEvents(
113
+ { type: 'desktopIndependentWindow', window: 42, mouse_events: true, inputEventsOutput: { output: 'segmented', segmentCallback: () => { } } },
114
+ 'DesktopIndependentWindow.onInputEventsSegment',
115
+ );
116
+ });
117
+
118
+ it('passes a singleFile inputEventsOutput through verbatim', async () => {
119
+ const { item } = await wire([
120
+ { type: 'display', display: 5, mouse_events: true, inputEventsOutput: { filename: 'events.json' } },
121
+ ]);
122
+ expect(item.inputEventsOutput).toEqual({ filename: 'events.json' });
123
+ });
124
+ });
125
+
126
+ describe('Recorder audio stream decoding (RPC -> AudioStreamBuffer)', () => {
127
+ function base64Samples(samples: number[]): string {
128
+ const floats = new Float32Array(samples);
129
+ return Buffer.from(floats.buffer, floats.byteOffset, floats.byteLength).toString('base64');
130
+ }
131
+
132
+ async function wireMicrophoneStream() {
133
+ const received: AudioStreamBuffer[] = [];
134
+ const { calls } = await wire([
135
+ { type: 'microphone', microphone: 'mic-1', output: 'stream', streamCallback: (buffer) => received.push(buffer) },
136
+ ]);
137
+ const closure = calls.closures.find((c) => c.prefix === 'MicrophoneStream.onAudioBuffer');
138
+ expect(closure).toBeDefined();
139
+ return { closure: closure!, received };
140
+ }
141
+
142
+ it('decodes base64 channel data into per-channel Float32Arrays', async () => {
143
+ const { closure, received } = await wireMicrophoneStream();
144
+ closure.handler({
145
+ sampleRate: 48000,
146
+ numberOfChannels: 2,
147
+ numberOfFrames: 3,
148
+ channelData: [base64Samples([0, 0.5, -0.5]), base64Samples([1, -1, 0.25])],
149
+ });
150
+ expect(received).toHaveLength(1);
151
+ expect(received[0]).toMatchObject({ sampleRate: 48000, numberOfChannels: 2, numberOfFrames: 3 });
152
+ expect(Array.from(received[0].channelData[0])).toEqual([0, 0.5, -0.5]);
153
+ expect(Array.from(received[0].channelData[1])).toEqual([1, -1, 0.25]);
154
+ });
155
+
156
+ it('drops malformed audio buffers instead of invoking the stream callback', async () => {
157
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { });
158
+ try {
159
+ const { closure, received } = await wireMicrophoneStream();
160
+ closure.handler({ sampleRate: 48000, numberOfChannels: 1, numberOfFrames: 3 }); // missing channelData
161
+ closure.handler({ sampleRate: 48000, numberOfChannels: 1, numberOfFrames: 3, channelData: [12345] }); // not base64 strings
162
+ expect(received).toHaveLength(0);
163
+ } finally {
164
+ consoleError.mockRestore();
165
+ }
166
+ });
167
+ });