@myerscarpenter/quest-dev 1.4.1 → 2.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.
Files changed (142) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.github/workflows/docs.yml +45 -0
  3. package/.github/workflows/publish.yml +11 -1
  4. package/README.md +27 -0
  5. package/build/cast/decoder.d.ts +48 -0
  6. package/build/cast/decoder.d.ts.map +1 -0
  7. package/build/cast/decoder.js +152 -0
  8. package/build/cast/decoder.js.map +1 -0
  9. package/build/cast/session.d.ts +87 -0
  10. package/build/cast/session.d.ts.map +1 -0
  11. package/build/cast/session.js +565 -0
  12. package/build/cast/session.js.map +1 -0
  13. package/build/commands/logcat.d.ts.map +1 -1
  14. package/build/commands/logcat.js +7 -6
  15. package/build/commands/logcat.js.map +1 -1
  16. package/build/commands/screenshot.d.ts.map +1 -1
  17. package/build/commands/screenshot.js +17 -20
  18. package/build/commands/screenshot.js.map +1 -1
  19. package/build/commands/stay-awake.d.ts +2 -15
  20. package/build/commands/stay-awake.d.ts.map +1 -1
  21. package/build/commands/stay-awake.js +14 -77
  22. package/build/commands/stay-awake.js.map +1 -1
  23. package/build/daemon/cast-manager.d.ts +42 -0
  24. package/build/daemon/cast-manager.d.ts.map +1 -0
  25. package/build/daemon/cast-manager.js +243 -0
  26. package/build/daemon/cast-manager.js.map +1 -0
  27. package/build/daemon/client.d.ts +40 -0
  28. package/build/daemon/client.d.ts.map +1 -0
  29. package/build/daemon/client.js +133 -0
  30. package/build/daemon/client.js.map +1 -0
  31. package/build/daemon/daemon.d.ts +20 -0
  32. package/build/daemon/daemon.d.ts.map +1 -0
  33. package/build/daemon/daemon.js +130 -0
  34. package/build/daemon/daemon.js.map +1 -0
  35. package/build/daemon/deploy.d.ts +44 -0
  36. package/build/daemon/deploy.d.ts.map +1 -0
  37. package/build/daemon/deploy.js +230 -0
  38. package/build/daemon/deploy.js.map +1 -0
  39. package/build/daemon/logcat-manager.d.ts +39 -0
  40. package/build/daemon/logcat-manager.d.ts.map +1 -0
  41. package/build/daemon/logcat-manager.js +194 -0
  42. package/build/daemon/logcat-manager.js.map +1 -0
  43. package/build/daemon/server.d.ts +19 -0
  44. package/build/daemon/server.d.ts.map +1 -0
  45. package/build/daemon/server.js +482 -0
  46. package/build/daemon/server.js.map +1 -0
  47. package/build/daemon/stay-awake-manager.d.ts +22 -0
  48. package/build/daemon/stay-awake-manager.d.ts.map +1 -0
  49. package/build/daemon/stay-awake-manager.js +74 -0
  50. package/build/daemon/stay-awake-manager.js.map +1 -0
  51. package/build/index.js +272 -45
  52. package/build/index.js.map +1 -1
  53. package/build/public/dashboard.js +749 -0
  54. package/build/public/index.html +12 -0
  55. package/build/public/style.css +106 -0
  56. package/build/utils/adb.d.ts +6 -0
  57. package/build/utils/adb.d.ts.map +1 -1
  58. package/build/utils/adb.js +62 -66
  59. package/build/utils/adb.js.map +1 -1
  60. package/build/utils/casting-apk.d.ts +40 -0
  61. package/build/utils/casting-apk.d.ts.map +1 -0
  62. package/build/utils/casting-apk.js +252 -0
  63. package/build/utils/casting-apk.js.map +1 -0
  64. package/build/utils/config.d.ts +5 -3
  65. package/build/utils/config.d.ts.map +1 -1
  66. package/build/utils/config.js +18 -38
  67. package/build/utils/config.js.map +1 -1
  68. package/build/utils/exec.d.ts +5 -0
  69. package/build/utils/exec.d.ts.map +1 -1
  70. package/build/utils/exec.js +17 -0
  71. package/build/utils/exec.js.map +1 -1
  72. package/build/utils/filename.d.ts +7 -1
  73. package/build/utils/filename.d.ts.map +1 -1
  74. package/build/utils/filename.js +17 -2
  75. package/build/utils/filename.js.map +1 -1
  76. package/build/utils/filename.test.js +33 -1
  77. package/build/utils/filename.test.js.map +1 -1
  78. package/build/utils/jpeg-comment.d.ts +14 -0
  79. package/build/utils/jpeg-comment.d.ts.map +1 -0
  80. package/build/utils/jpeg-comment.js +28 -0
  81. package/build/utils/jpeg-comment.js.map +1 -0
  82. package/build/utils/test-properties.d.ts +34 -0
  83. package/build/utils/test-properties.d.ts.map +1 -0
  84. package/build/utils/test-properties.js +73 -0
  85. package/build/utils/test-properties.js.map +1 -0
  86. package/package.json +11 -5
  87. package/packages/cast2-protocol/README.md +86 -0
  88. package/packages/cast2-protocol/docs/_config.yml +4 -0
  89. package/packages/cast2-protocol/docs/feature-flags.md +102 -0
  90. package/packages/cast2-protocol/docs/index.md +24 -0
  91. package/packages/cast2-protocol/docs/open-investigations.md +149 -0
  92. package/packages/cast2-protocol/docs/protocol.md +602 -0
  93. package/packages/cast2-protocol/package.json +46 -0
  94. package/packages/cast2-protocol/src/constants.ts +65 -0
  95. package/packages/cast2-protocol/src/index.ts +7 -0
  96. package/packages/cast2-protocol/src/mgik.ts +69 -0
  97. package/packages/cast2-protocol/src/mud.ts +294 -0
  98. package/packages/cast2-protocol/src/pose.ts +99 -0
  99. package/packages/cast2-protocol/src/resolutions.ts +34 -0
  100. package/packages/cast2-protocol/src/types.ts +64 -0
  101. package/packages/cast2-protocol/src/xrsp.ts +73 -0
  102. package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
  103. package/packages/cast2-protocol/tests/mud.test.ts +295 -0
  104. package/packages/cast2-protocol/tests/pose.test.ts +173 -0
  105. package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
  106. package/packages/cast2-protocol/tsconfig.json +20 -0
  107. package/pnpm-workspace.yaml +2 -0
  108. package/src/cast/decoder.ts +178 -0
  109. package/src/cast/session.ts +708 -0
  110. package/src/commands/logcat.ts +6 -5
  111. package/src/commands/screenshot.ts +19 -13
  112. package/src/commands/stay-awake.ts +22 -91
  113. package/src/daemon/adbkit-apkreader.d.ts +14 -0
  114. package/src/daemon/cast-manager.ts +282 -0
  115. package/src/daemon/client.ts +166 -0
  116. package/src/daemon/daemon.ts +169 -0
  117. package/src/daemon/deploy.ts +307 -0
  118. package/src/daemon/logcat-manager.ts +229 -0
  119. package/src/daemon/server.ts +595 -0
  120. package/src/daemon/stay-awake-manager.ts +83 -0
  121. package/src/index.ts +326 -56
  122. package/src/public/dashboard.js +288 -0
  123. package/src/public/index.html +12 -0
  124. package/src/public/style.css +106 -0
  125. package/src/utils/adb.ts +70 -57
  126. package/src/utils/casting-apk.ts +276 -0
  127. package/src/utils/config.ts +18 -36
  128. package/src/utils/exec.ts +20 -0
  129. package/src/utils/filename.test.ts +41 -1
  130. package/src/utils/filename.ts +18 -2
  131. package/src/utils/jpeg-comment.ts +30 -0
  132. package/src/utils/test-properties.ts +94 -0
  133. package/tests/cast/auto-layer.test.ts +87 -0
  134. package/tests/cast/decoder.test.ts +82 -0
  135. package/tests/cast/session-restart.test.ts +107 -0
  136. package/tests/config.test.ts +17 -22
  137. package/tests/daemon/api-status.test.ts +82 -0
  138. package/tests/daemon/cast-manager.test.ts +69 -0
  139. package/tests/daemon/mjpeg-stream.test.ts +144 -0
  140. package/tests/daemon/pose-endpoint.test.ts +63 -0
  141. package/tests/daemon/start-guard.test.ts +77 -0
  142. package/vitest.config.ts +10 -0
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { extractJpegFrames } from "../../src/cast/decoder.js";
3
+
4
+ describe("extractJpegFrames", () => {
5
+ const SOI = Buffer.from([0xff, 0xd8]);
6
+ const EOI = Buffer.from([0xff, 0xd9]);
7
+
8
+ function makeJpeg(bodySize: number): Buffer {
9
+ const body = Buffer.alloc(bodySize, 0x42);
10
+ return Buffer.concat([SOI, body, EOI]);
11
+ }
12
+
13
+ it("returns no frames from empty buffer", () => {
14
+ const result = extractJpegFrames(Buffer.alloc(0));
15
+ expect(result.frames).toHaveLength(0);
16
+ expect(result.remainder.length).toBe(0);
17
+ });
18
+
19
+ it("returns no frames from buffer without markers", () => {
20
+ const result = extractJpegFrames(Buffer.alloc(100, 0x42));
21
+ expect(result.frames).toHaveLength(0);
22
+ expect(result.remainder.length).toBe(0);
23
+ });
24
+
25
+ it("extracts a single complete JPEG frame", () => {
26
+ const jpeg = makeJpeg(10);
27
+ const result = extractJpegFrames(jpeg);
28
+ expect(result.frames).toHaveLength(1);
29
+ expect(result.frames[0]).toEqual(jpeg);
30
+ expect(result.remainder.length).toBe(0);
31
+ });
32
+
33
+ it("extracts multiple JPEG frames", () => {
34
+ const j1 = makeJpeg(5);
35
+ const j2 = makeJpeg(8);
36
+ const buf = Buffer.concat([j1, j2]);
37
+ const result = extractJpegFrames(buf);
38
+ expect(result.frames).toHaveLength(2);
39
+ expect(result.frames[0]).toEqual(j1);
40
+ expect(result.frames[1]).toEqual(j2);
41
+ expect(result.remainder.length).toBe(0);
42
+ });
43
+
44
+ it("handles partial frame (SOI without EOI)", () => {
45
+ const partial = Buffer.concat([SOI, Buffer.alloc(10, 0x42)]);
46
+ const result = extractJpegFrames(partial);
47
+ expect(result.frames).toHaveLength(0);
48
+ expect(result.remainder).toEqual(partial);
49
+ });
50
+
51
+ it("extracts complete frame and keeps partial remainder", () => {
52
+ const complete = makeJpeg(5);
53
+ const partial = Buffer.concat([SOI, Buffer.alloc(3, 0x42)]);
54
+ const buf = Buffer.concat([complete, partial]);
55
+ const result = extractJpegFrames(buf);
56
+ expect(result.frames).toHaveLength(1);
57
+ expect(result.frames[0]).toEqual(complete);
58
+ expect(result.remainder).toEqual(partial);
59
+ });
60
+
61
+ it("skips garbage before SOI", () => {
62
+ const garbage = Buffer.alloc(20, 0xab);
63
+ const jpeg = makeJpeg(5);
64
+ const buf = Buffer.concat([garbage, jpeg]);
65
+ const result = extractJpegFrames(buf);
66
+ expect(result.frames).toHaveLength(1);
67
+ expect(result.frames[0]).toEqual(jpeg);
68
+ });
69
+
70
+ it("handles zero-length body JPEG", () => {
71
+ const jpeg = Buffer.concat([SOI, EOI]);
72
+ const result = extractJpegFrames(jpeg);
73
+ expect(result.frames).toHaveLength(1);
74
+ expect(result.frames[0]).toEqual(jpeg);
75
+ });
76
+
77
+ it("handles buffer that is just SOI", () => {
78
+ const result = extractJpegFrames(SOI);
79
+ expect(result.frames).toHaveLength(0);
80
+ expect(result.remainder).toEqual(SOI);
81
+ });
82
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { createServer, type Server, type Socket } from "node:net";
3
+
4
+ /**
5
+ * Bug: session.restart() calls stop() which closes the TCP server,
6
+ * then calls start() which expects the server to be listening.
7
+ * The server must be re-bound (listen again) after close.
8
+ *
9
+ * We test the core invariant: after restart(), the server is listening.
10
+ */
11
+
12
+ describe("TCP server re-bind after close", () => {
13
+ let server: Server;
14
+ let port: number;
15
+
16
+ beforeEach(async () => {
17
+ server = createServer();
18
+ // Bind to random port
19
+ await new Promise<void>((resolve) => {
20
+ server.listen(0, "127.0.0.1", () => resolve());
21
+ });
22
+ port = (server.address() as { port: number }).port;
23
+ });
24
+
25
+ it("closed server does not accept connections", async () => {
26
+ server.close();
27
+ // Wait for close to complete
28
+ await new Promise((r) => setTimeout(r, 50));
29
+
30
+ // Try to connect — should fail
31
+ const result = await new Promise<string>((resolve) => {
32
+ const sock = new (require("node:net").Socket)();
33
+ sock.on("error", () => resolve("refused"));
34
+ sock.on("connect", () => { sock.destroy(); resolve("connected"); });
35
+ sock.connect(port, "127.0.0.1");
36
+ });
37
+ expect(result).toBe("refused");
38
+ });
39
+
40
+ it("server can re-listen after close", async () => {
41
+ // Close then re-listen on same port
42
+ await new Promise<void>((resolve) => server.close(() => resolve()));
43
+ await new Promise<void>((resolve, reject) => {
44
+ server.listen(port, "127.0.0.1", () => resolve());
45
+ server.on("error", reject);
46
+ });
47
+
48
+ // Should accept connections again
49
+ const result = await new Promise<string>((resolve) => {
50
+ const sock = new (require("node:net").Socket)();
51
+ sock.on("error", () => resolve("refused"));
52
+ sock.on("connect", () => { sock.destroy(); resolve("connected"); });
53
+ sock.connect(port, "127.0.0.1");
54
+ });
55
+ expect(result).toBe("connected");
56
+ server.close();
57
+ });
58
+ });
59
+
60
+ describe("connection count guard", () => {
61
+ it("should only process exactly 2 connections", () => {
62
+ const sockets: string[] = [];
63
+ let controlSocket: string | null = null;
64
+ let videoSocket: string | null = null;
65
+ let assignCount = 0;
66
+
67
+ // Simulates fixed connection handler using === 2
68
+ function onConnection(id: string) {
69
+ sockets.push(id);
70
+ if (sockets.length === 2) {
71
+ controlSocket = sockets[0];
72
+ videoSocket = sockets[1];
73
+ assignCount++;
74
+ }
75
+ }
76
+
77
+ onConnection("sock-a");
78
+ onConnection("sock-b");
79
+ onConnection("sock-c"); // spurious 3rd connection
80
+
81
+ expect(assignCount).toBe(1); // assigned exactly once
82
+ expect(controlSocket).toBe("sock-a");
83
+ expect(videoSocket).toBe("sock-b");
84
+ });
85
+
86
+ it("bug: >= 2 fires on every subsequent connection", () => {
87
+ const sockets: string[] = [];
88
+ let videoSocket: string | null = null;
89
+ let assignCount = 0;
90
+
91
+ // Old buggy handler using >= 2
92
+ function onConnection(id: string) {
93
+ sockets.push(id);
94
+ if (sockets.length >= 2) {
95
+ videoSocket = sockets[1];
96
+ assignCount++;
97
+ }
98
+ }
99
+
100
+ onConnection("sock-a");
101
+ onConnection("sock-b");
102
+ onConnection("sock-c"); // spurious 3rd
103
+
104
+ expect(assignCount).toBe(2); // BUG: assigned twice
105
+ expect(videoSocket).toBe("sock-b"); // at least video isn't overwritten to sock-c since sockets[1] is stable
106
+ });
107
+ });
@@ -11,7 +11,7 @@ beforeEach(() => {
11
11
  });
12
12
 
13
13
  describe('loadConfig', () => {
14
- it('returns empty object when no config files exist', () => {
14
+ it('returns empty object when no config file exists', () => {
15
15
  mockReadFileSync.mockImplementation(() => {
16
16
  throw new Error('ENOENT');
17
17
  });
@@ -20,31 +20,32 @@ describe('loadConfig', () => {
20
20
  expect(config).toEqual({});
21
21
  });
22
22
 
23
- it('reads .quest-dev.json from cwd when present', () => {
23
+ it('reads ~/.config/quest-dev/config.json', () => {
24
24
  mockReadFileSync.mockImplementation((path) => {
25
- if (String(path).endsWith('.quest-dev.json')) {
26
- return JSON.stringify({ pin: '1234' });
25
+ if (String(path).endsWith('config.json')) {
26
+ return JSON.stringify({ pin: '1234', port: 8091 });
27
27
  }
28
28
  throw new Error('ENOENT');
29
29
  });
30
30
 
31
31
  const config = loadConfig();
32
32
  expect(config.pin).toBe('1234');
33
+ expect(config.port).toBe(8091);
33
34
  });
34
35
 
35
- it('merges configs: local file wins over global file', () => {
36
- mockReadFileSync.mockImplementation((path) => {
37
- if (String(path).endsWith('.quest-dev.json')) {
38
- return JSON.stringify({ pin: 'local-pin', idleTimeout: 5000 });
39
- }
40
- if (String(path).endsWith('config.json')) {
41
- return JSON.stringify({ pin: 'global-pin', lowBattery: 15 });
42
- }
43
- throw new Error('ENOENT');
44
- });
36
+ it('returns all config fields', () => {
37
+ mockReadFileSync.mockReturnValue(JSON.stringify({
38
+ pin: 'test-pin',
39
+ port: 9000,
40
+ device: '192.168.1.100',
41
+ idleTimeout: 5000,
42
+ lowBattery: 15,
43
+ }));
45
44
 
46
45
  const config = loadConfig();
47
- expect(config.pin).toBe('local-pin');
46
+ expect(config.pin).toBe('test-pin');
47
+ expect(config.port).toBe(9000);
48
+ expect(config.device).toBe('192.168.1.100');
48
49
  expect(config.idleTimeout).toBe(5000);
49
50
  expect(config.lowBattery).toBe(15);
50
51
  });
@@ -52,18 +53,12 @@ describe('loadConfig', () => {
52
53
 
53
54
  describe('loadPin', () => {
54
55
  it('returns CLI pin when provided', () => {
55
- // Should not even read config files
56
56
  const pin = loadPin('cli-pin');
57
57
  expect(pin).toBe('cli-pin');
58
58
  });
59
59
 
60
60
  it('falls back to config file pin', () => {
61
- mockReadFileSync.mockImplementation((path) => {
62
- if (String(path).endsWith('.quest-dev.json')) {
63
- return JSON.stringify({ pin: 'config-pin' });
64
- }
65
- throw new Error('ENOENT');
66
- });
61
+ mockReadFileSync.mockReturnValue(JSON.stringify({ pin: 'config-pin' }));
67
62
 
68
63
  const pin = loadPin();
69
64
  expect(pin).toBe('config-pin');
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ /**
4
+ * Bug: Dashboard api() helper doesn't check HTTP status codes.
5
+ * A 500 response with HTML body would throw an uncaught JSON parse error.
6
+ * Fix: check response.ok before parsing JSON.
7
+ */
8
+
9
+ /** Fixed api helper that checks HTTP status. */
10
+ async function apiFetch(
11
+ fetcher: typeof fetch,
12
+ method: string,
13
+ path: string,
14
+ body?: unknown,
15
+ ): Promise<unknown> {
16
+ const opts: RequestInit = { method };
17
+ if (body) {
18
+ opts.headers = { "Content-Type": "application/json" };
19
+ opts.body = JSON.stringify(body);
20
+ }
21
+ const r = await fetcher(path, opts);
22
+ if (!r.ok) {
23
+ // Try to parse JSON error, fall back to status text
24
+ let msg: string;
25
+ try {
26
+ const json = await r.json();
27
+ msg = json.error ?? r.statusText;
28
+ } catch {
29
+ msg = r.statusText;
30
+ }
31
+ throw new Error(`HTTP ${r.status}: ${msg}`);
32
+ }
33
+ return r.json();
34
+ }
35
+
36
+ function mockFetch(status: number, body: unknown, contentType = "application/json"): typeof fetch {
37
+ return vi.fn().mockResolvedValue({
38
+ ok: status >= 200 && status < 300,
39
+ status,
40
+ statusText: status === 500 ? "Internal Server Error" : "OK",
41
+ json: () => Promise.resolve(body),
42
+ headers: new Headers({ "content-type": contentType }),
43
+ }) as unknown as typeof fetch;
44
+ }
45
+
46
+ describe("api fetch with status checking", () => {
47
+ it("returns JSON on 200", async () => {
48
+ const f = mockFetch(200, { ok: true });
49
+ const result = await apiFetch(f, "GET", "/status");
50
+ expect(result).toEqual({ ok: true });
51
+ });
52
+
53
+ it("throws on 500 with JSON error body", async () => {
54
+ const f = mockFetch(500, { error: "something broke" });
55
+ await expect(apiFetch(f, "POST", "/cast/start")).rejects.toThrow(
56
+ "HTTP 500: something broke",
57
+ );
58
+ });
59
+
60
+ it("throws on 503 with fallback to statusText", async () => {
61
+ const f = vi.fn().mockResolvedValue({
62
+ ok: false,
63
+ status: 503,
64
+ statusText: "Service Unavailable",
65
+ json: () => Promise.reject(new Error("not json")),
66
+ }) as unknown as typeof fetch;
67
+
68
+ await expect(apiFetch(f, "GET", "/cast/screenshot")).rejects.toThrow(
69
+ "HTTP 503: Service Unavailable",
70
+ );
71
+ });
72
+
73
+ it("passes body correctly", async () => {
74
+ const f = mockFetch(200, { ok: true });
75
+ await apiFetch(f, "POST", "/cast/pose", { dz: 0.3 });
76
+ expect(f).toHaveBeenCalledWith("/cast/pose", {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: '{"dz":0.3}',
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ServerResponse } from "node:http";
3
+
4
+ /**
5
+ * Minimal extraction of CastManager.broadcast() logic for unit testing
6
+ * without needing to instantiate the full CastManager (which requires ADB).
7
+ */
8
+ function broadcast(
9
+ sseClients: Set<ServerResponse>,
10
+ event: string,
11
+ data: unknown,
12
+ ): void {
13
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
14
+ for (const res of sseClients) {
15
+ try {
16
+ res.write(msg);
17
+ } catch {
18
+ sseClients.delete(res);
19
+ }
20
+ }
21
+ }
22
+
23
+ function makeFakeRes(opts?: { throwOnWrite?: boolean }): ServerResponse {
24
+ return {
25
+ write: opts?.throwOnWrite
26
+ ? () => { throw new Error("socket destroyed"); }
27
+ : vi.fn(),
28
+ } as unknown as ServerResponse;
29
+ }
30
+
31
+ describe("SSE broadcast", () => {
32
+ it("writes to all live clients", () => {
33
+ const clients = new Set<ServerResponse>();
34
+ const a = makeFakeRes();
35
+ const b = makeFakeRes();
36
+ clients.add(a);
37
+ clients.add(b);
38
+
39
+ broadcast(clients, "status", { ok: true });
40
+
41
+ expect(a.write).toHaveBeenCalledOnce();
42
+ expect(b.write).toHaveBeenCalledOnce();
43
+ expect(clients.size).toBe(2);
44
+ });
45
+
46
+ it("removes dead clients on write error", () => {
47
+ const clients = new Set<ServerResponse>();
48
+ const alive = makeFakeRes();
49
+ const dead = makeFakeRes({ throwOnWrite: true });
50
+ clients.add(alive);
51
+ clients.add(dead);
52
+
53
+ broadcast(clients, "status", { ok: true });
54
+
55
+ expect(alive.write).toHaveBeenCalledOnce();
56
+ expect(clients.size).toBe(1);
57
+ expect(clients.has(alive)).toBe(true);
58
+ expect(clients.has(dead)).toBe(false);
59
+ });
60
+
61
+ it("handles all clients dead", () => {
62
+ const clients = new Set<ServerResponse>();
63
+ clients.add(makeFakeRes({ throwOnWrite: true }));
64
+ clients.add(makeFakeRes({ throwOnWrite: true }));
65
+
66
+ expect(() => broadcast(clients, "status", {})).not.toThrow();
67
+ expect(clients.size).toBe(0);
68
+ });
69
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ /**
4
+ * MJPEG stream cleanup logic.
5
+ * Bug: interval can fire after reply.raw.end() if session stops
6
+ * and client hasn't disconnected yet.
7
+ */
8
+
9
+ interface FakeResponse {
10
+ writeHead: ReturnType<typeof vi.fn>;
11
+ write: ReturnType<typeof vi.fn>;
12
+ end: ReturnType<typeof vi.fn>;
13
+ ended: boolean;
14
+ }
15
+
16
+ function createFakeResponse(): FakeResponse {
17
+ const res: FakeResponse = {
18
+ writeHead: vi.fn(),
19
+ write: vi.fn(),
20
+ end: vi.fn(() => { res.ended = true; }),
21
+ ended: false,
22
+ };
23
+ return res;
24
+ }
25
+
26
+ /**
27
+ * Fixed MJPEG stream loop: checks `ended` before writing,
28
+ * and wraps writes in try-catch.
29
+ */
30
+ function startMjpegStream(
31
+ res: FakeResponse,
32
+ getSession: () => { running: boolean; getScreenshot: () => Buffer | null } | null,
33
+ onCleanup: () => void,
34
+ ): { interval: ReturnType<typeof setInterval>; cleanup: () => void } {
35
+ let cleaned = false;
36
+ const cleanup = () => {
37
+ if (cleaned) return;
38
+ cleaned = true;
39
+ clearInterval(interval);
40
+ try { res.end(); } catch { /* ignore */ }
41
+ onCleanup();
42
+ };
43
+
44
+ const interval = setInterval(() => {
45
+ const s = getSession();
46
+ if (!s?.running) {
47
+ cleanup();
48
+ return;
49
+ }
50
+ if (res.ended) {
51
+ cleanup();
52
+ return;
53
+ }
54
+ const jpeg = s.getScreenshot();
55
+ if (jpeg) {
56
+ try {
57
+ res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`);
58
+ res.write(jpeg);
59
+ res.write("\r\n");
60
+ } catch {
61
+ cleanup();
62
+ }
63
+ }
64
+ }, 50);
65
+
66
+ return { interval, cleanup };
67
+ }
68
+
69
+ describe("MJPEG stream cleanup", () => {
70
+ beforeEach(() => { vi.useFakeTimers(); });
71
+ afterEach(() => { vi.useRealTimers(); });
72
+
73
+ it("writes frames while session is running", () => {
74
+ const res = createFakeResponse();
75
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
76
+ const session = { running: true, getScreenshot: () => jpeg };
77
+ const cleanupFn = vi.fn();
78
+
79
+ startMjpegStream(res, () => session, cleanupFn);
80
+ vi.advanceTimersByTime(100);
81
+
82
+ expect(res.write).toHaveBeenCalled();
83
+ expect(cleanupFn).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("cleans up when session stops", () => {
87
+ const res = createFakeResponse();
88
+ const session = { running: true, getScreenshot: () => Buffer.from([1]) };
89
+ const cleanupFn = vi.fn();
90
+
91
+ startMjpegStream(res, () => session, cleanupFn);
92
+ vi.advanceTimersByTime(50);
93
+
94
+ session.running = false;
95
+ vi.advanceTimersByTime(100);
96
+
97
+ expect(res.end).toHaveBeenCalled();
98
+ expect(cleanupFn).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it("does not write after cleanup", () => {
102
+ const res = createFakeResponse();
103
+ const session = { running: true, getScreenshot: () => Buffer.from([1]) };
104
+ const cleanupFn = vi.fn();
105
+
106
+ const { cleanup } = startMjpegStream(res, () => session, cleanupFn);
107
+
108
+ // Simulate client disconnect
109
+ cleanup();
110
+ res.write.mockClear();
111
+
112
+ vi.advanceTimersByTime(200);
113
+
114
+ // No more writes after cleanup
115
+ expect(res.write).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it("cleanup is idempotent", () => {
119
+ const res = createFakeResponse();
120
+ const session = { running: true, getScreenshot: () => Buffer.from([1]) };
121
+ const cleanupFn = vi.fn();
122
+
123
+ const { cleanup } = startMjpegStream(res, () => session, cleanupFn);
124
+
125
+ cleanup();
126
+ cleanup();
127
+ cleanup();
128
+
129
+ expect(res.end).toHaveBeenCalledOnce();
130
+ expect(cleanupFn).toHaveBeenCalledOnce();
131
+ });
132
+
133
+ it("handles write errors gracefully", () => {
134
+ const res = createFakeResponse();
135
+ res.write.mockImplementation(() => { throw new Error("broken pipe"); });
136
+ const session = { running: true, getScreenshot: () => Buffer.from([1]) };
137
+ const cleanupFn = vi.fn();
138
+
139
+ startMjpegStream(res, () => session, cleanupFn);
140
+ vi.advanceTimersByTime(100);
141
+
142
+ expect(cleanupFn).toHaveBeenCalledOnce();
143
+ });
144
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createPoseState, updatePose, setPoseOffset, type PoseState } from "@myerscarpenter/cast2-protocol";
3
+
4
+ /**
5
+ * Simulates the /cast/pose endpoint logic:
6
+ * offset and delta should be mutually exclusive.
7
+ */
8
+ function applyPoseRequest(
9
+ state: PoseState,
10
+ data: Record<string, number>,
11
+ ): PoseState {
12
+ // Direct offset — takes priority
13
+ if ("x" in data || "y" in data || "z" in data || "yaw" in data || "pitch" in data) {
14
+ return setPoseOffset(state, {
15
+ x: data.x,
16
+ y: data.y,
17
+ z: data.z,
18
+ yaw: data.yaw,
19
+ pitch: data.pitch,
20
+ });
21
+ }
22
+ // Incremental deltas — only if no offset fields present
23
+ if ("dx" in data || "dy" in data || "dz" in data || "d_yaw" in data || "d_pitch" in data) {
24
+ return updatePose(state, {
25
+ dForward: data.dz ?? 0,
26
+ dStrafe: data.dx ?? 0,
27
+ dUp: data.dy ?? 0,
28
+ dYaw: data.d_yaw ?? 0,
29
+ dPitch: data.d_pitch ?? 0,
30
+ });
31
+ }
32
+ return state;
33
+ }
34
+
35
+ describe("/cast/pose endpoint logic", () => {
36
+ it("applies offset when offset fields provided", () => {
37
+ const state = createPoseState();
38
+ const result = applyPoseRequest(state, { x: 1.0, y: 2.0 });
39
+ expect(result.x).toBeCloseTo(1.0);
40
+ expect(result.y).toBeCloseTo(2.0);
41
+ });
42
+
43
+ it("applies delta when delta fields provided", () => {
44
+ const state = createPoseState();
45
+ const result = applyPoseRequest(state, { dz: 0.3 }); // forward
46
+ expect(result.z).toBeCloseTo(-0.3); // OpenXR: forward = -Z
47
+ });
48
+
49
+ it("does not double-apply when both offset and delta provided", () => {
50
+ const state = createPoseState();
51
+ // If both are present, offset wins — delta is NOT also applied
52
+ const result = applyPoseRequest(state, { x: 1.0, dz: 0.3 });
53
+ expect(result.x).toBeCloseTo(1.0);
54
+ // z should be 0 (offset only set x, delta was ignored)
55
+ expect(result.z).toBeCloseTo(0);
56
+ });
57
+
58
+ it("returns unchanged state when no recognized fields", () => {
59
+ const state = createPoseState();
60
+ const result = applyPoseRequest(state, { foo: 42 } as any);
61
+ expect(result).toBe(state);
62
+ });
63
+ });