@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.
- package/.claude/settings.local.json +7 -0
- package/.github/workflows/docs.yml +45 -0
- package/.github/workflows/publish.yml +11 -1
- package/README.md +27 -0
- package/build/cast/decoder.d.ts +48 -0
- package/build/cast/decoder.d.ts.map +1 -0
- package/build/cast/decoder.js +152 -0
- package/build/cast/decoder.js.map +1 -0
- package/build/cast/session.d.ts +87 -0
- package/build/cast/session.d.ts.map +1 -0
- package/build/cast/session.js +565 -0
- package/build/cast/session.js.map +1 -0
- package/build/commands/logcat.d.ts.map +1 -1
- package/build/commands/logcat.js +7 -6
- package/build/commands/logcat.js.map +1 -1
- package/build/commands/screenshot.d.ts.map +1 -1
- package/build/commands/screenshot.js +17 -20
- package/build/commands/screenshot.js.map +1 -1
- package/build/commands/stay-awake.d.ts +2 -15
- package/build/commands/stay-awake.d.ts.map +1 -1
- package/build/commands/stay-awake.js +14 -77
- package/build/commands/stay-awake.js.map +1 -1
- package/build/daemon/cast-manager.d.ts +42 -0
- package/build/daemon/cast-manager.d.ts.map +1 -0
- package/build/daemon/cast-manager.js +243 -0
- package/build/daemon/cast-manager.js.map +1 -0
- package/build/daemon/client.d.ts +40 -0
- package/build/daemon/client.d.ts.map +1 -0
- package/build/daemon/client.js +133 -0
- package/build/daemon/client.js.map +1 -0
- package/build/daemon/daemon.d.ts +20 -0
- package/build/daemon/daemon.d.ts.map +1 -0
- package/build/daemon/daemon.js +130 -0
- package/build/daemon/daemon.js.map +1 -0
- package/build/daemon/deploy.d.ts +44 -0
- package/build/daemon/deploy.d.ts.map +1 -0
- package/build/daemon/deploy.js +230 -0
- package/build/daemon/deploy.js.map +1 -0
- package/build/daemon/logcat-manager.d.ts +39 -0
- package/build/daemon/logcat-manager.d.ts.map +1 -0
- package/build/daemon/logcat-manager.js +194 -0
- package/build/daemon/logcat-manager.js.map +1 -0
- package/build/daemon/server.d.ts +19 -0
- package/build/daemon/server.d.ts.map +1 -0
- package/build/daemon/server.js +482 -0
- package/build/daemon/server.js.map +1 -0
- package/build/daemon/stay-awake-manager.d.ts +22 -0
- package/build/daemon/stay-awake-manager.d.ts.map +1 -0
- package/build/daemon/stay-awake-manager.js +74 -0
- package/build/daemon/stay-awake-manager.js.map +1 -0
- package/build/index.js +272 -45
- package/build/index.js.map +1 -1
- package/build/public/dashboard.js +749 -0
- package/build/public/index.html +12 -0
- package/build/public/style.css +106 -0
- package/build/utils/adb.d.ts +6 -0
- package/build/utils/adb.d.ts.map +1 -1
- package/build/utils/adb.js +62 -66
- package/build/utils/adb.js.map +1 -1
- package/build/utils/casting-apk.d.ts +40 -0
- package/build/utils/casting-apk.d.ts.map +1 -0
- package/build/utils/casting-apk.js +252 -0
- package/build/utils/casting-apk.js.map +1 -0
- package/build/utils/config.d.ts +5 -3
- package/build/utils/config.d.ts.map +1 -1
- package/build/utils/config.js +18 -38
- package/build/utils/config.js.map +1 -1
- package/build/utils/exec.d.ts +5 -0
- package/build/utils/exec.d.ts.map +1 -1
- package/build/utils/exec.js +17 -0
- package/build/utils/exec.js.map +1 -1
- package/build/utils/filename.d.ts +7 -1
- package/build/utils/filename.d.ts.map +1 -1
- package/build/utils/filename.js +17 -2
- package/build/utils/filename.js.map +1 -1
- package/build/utils/filename.test.js +33 -1
- package/build/utils/filename.test.js.map +1 -1
- package/build/utils/jpeg-comment.d.ts +14 -0
- package/build/utils/jpeg-comment.d.ts.map +1 -0
- package/build/utils/jpeg-comment.js +28 -0
- package/build/utils/jpeg-comment.js.map +1 -0
- package/build/utils/test-properties.d.ts +34 -0
- package/build/utils/test-properties.d.ts.map +1 -0
- package/build/utils/test-properties.js +73 -0
- package/build/utils/test-properties.js.map +1 -0
- package/package.json +11 -5
- package/packages/cast2-protocol/README.md +86 -0
- package/packages/cast2-protocol/docs/_config.yml +4 -0
- package/packages/cast2-protocol/docs/feature-flags.md +102 -0
- package/packages/cast2-protocol/docs/index.md +24 -0
- package/packages/cast2-protocol/docs/open-investigations.md +149 -0
- package/packages/cast2-protocol/docs/protocol.md +602 -0
- package/packages/cast2-protocol/package.json +46 -0
- package/packages/cast2-protocol/src/constants.ts +65 -0
- package/packages/cast2-protocol/src/index.ts +7 -0
- package/packages/cast2-protocol/src/mgik.ts +69 -0
- package/packages/cast2-protocol/src/mud.ts +294 -0
- package/packages/cast2-protocol/src/pose.ts +99 -0
- package/packages/cast2-protocol/src/resolutions.ts +34 -0
- package/packages/cast2-protocol/src/types.ts +64 -0
- package/packages/cast2-protocol/src/xrsp.ts +73 -0
- package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
- package/packages/cast2-protocol/tests/mud.test.ts +295 -0
- package/packages/cast2-protocol/tests/pose.test.ts +173 -0
- package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
- package/packages/cast2-protocol/tsconfig.json +20 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/cast/decoder.ts +178 -0
- package/src/cast/session.ts +708 -0
- package/src/commands/logcat.ts +6 -5
- package/src/commands/screenshot.ts +19 -13
- package/src/commands/stay-awake.ts +22 -91
- package/src/daemon/adbkit-apkreader.d.ts +14 -0
- package/src/daemon/cast-manager.ts +282 -0
- package/src/daemon/client.ts +166 -0
- package/src/daemon/daemon.ts +169 -0
- package/src/daemon/deploy.ts +307 -0
- package/src/daemon/logcat-manager.ts +229 -0
- package/src/daemon/server.ts +595 -0
- package/src/daemon/stay-awake-manager.ts +83 -0
- package/src/index.ts +326 -56
- package/src/public/dashboard.js +288 -0
- package/src/public/index.html +12 -0
- package/src/public/style.css +106 -0
- package/src/utils/adb.ts +70 -57
- package/src/utils/casting-apk.ts +276 -0
- package/src/utils/config.ts +18 -36
- package/src/utils/exec.ts +20 -0
- package/src/utils/filename.test.ts +41 -1
- package/src/utils/filename.ts +18 -2
- package/src/utils/jpeg-comment.ts +30 -0
- package/src/utils/test-properties.ts +94 -0
- package/tests/cast/auto-layer.test.ts +87 -0
- package/tests/cast/decoder.test.ts +82 -0
- package/tests/cast/session-restart.test.ts +107 -0
- package/tests/config.test.ts +17 -22
- package/tests/daemon/api-status.test.ts +82 -0
- package/tests/daemon/cast-manager.test.ts +69 -0
- package/tests/daemon/mjpeg-stream.test.ts +144 -0
- package/tests/daemon/pose-endpoint.test.ts +63 -0
- package/tests/daemon/start-guard.test.ts +77 -0
- 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
|
+
});
|
package/tests/config.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ beforeEach(() => {
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
describe('loadConfig', () => {
|
|
14
|
-
it('returns empty object when no config
|
|
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
|
|
23
|
+
it('reads ~/.config/quest-dev/config.json', () => {
|
|
24
24
|
mockReadFileSync.mockImplementation((path) => {
|
|
25
|
-
if (String(path).endsWith('.
|
|
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('
|
|
36
|
-
mockReadFileSync.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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('
|
|
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.
|
|
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
|
+
});
|