@myerscarpenter/quest-dev 1.4.0 → 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 (151) 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/open.d.ts.map +1 -1
  17. package/build/commands/open.js +9 -4
  18. package/build/commands/open.js.map +1 -1
  19. package/build/commands/screenshot.d.ts.map +1 -1
  20. package/build/commands/screenshot.js +17 -20
  21. package/build/commands/screenshot.js.map +1 -1
  22. package/build/commands/stay-awake.d.ts +2 -15
  23. package/build/commands/stay-awake.d.ts.map +1 -1
  24. package/build/commands/stay-awake.js +14 -77
  25. package/build/commands/stay-awake.js.map +1 -1
  26. package/build/daemon/cast-manager.d.ts +42 -0
  27. package/build/daemon/cast-manager.d.ts.map +1 -0
  28. package/build/daemon/cast-manager.js +243 -0
  29. package/build/daemon/cast-manager.js.map +1 -0
  30. package/build/daemon/client.d.ts +40 -0
  31. package/build/daemon/client.d.ts.map +1 -0
  32. package/build/daemon/client.js +133 -0
  33. package/build/daemon/client.js.map +1 -0
  34. package/build/daemon/daemon.d.ts +20 -0
  35. package/build/daemon/daemon.d.ts.map +1 -0
  36. package/build/daemon/daemon.js +130 -0
  37. package/build/daemon/daemon.js.map +1 -0
  38. package/build/daemon/deploy.d.ts +44 -0
  39. package/build/daemon/deploy.d.ts.map +1 -0
  40. package/build/daemon/deploy.js +230 -0
  41. package/build/daemon/deploy.js.map +1 -0
  42. package/build/daemon/logcat-manager.d.ts +39 -0
  43. package/build/daemon/logcat-manager.d.ts.map +1 -0
  44. package/build/daemon/logcat-manager.js +194 -0
  45. package/build/daemon/logcat-manager.js.map +1 -0
  46. package/build/daemon/server.d.ts +19 -0
  47. package/build/daemon/server.d.ts.map +1 -0
  48. package/build/daemon/server.js +482 -0
  49. package/build/daemon/server.js.map +1 -0
  50. package/build/daemon/stay-awake-manager.d.ts +22 -0
  51. package/build/daemon/stay-awake-manager.d.ts.map +1 -0
  52. package/build/daemon/stay-awake-manager.js +74 -0
  53. package/build/daemon/stay-awake-manager.js.map +1 -0
  54. package/build/index.js +285 -45
  55. package/build/index.js.map +1 -1
  56. package/build/public/dashboard.js +749 -0
  57. package/build/public/index.html +12 -0
  58. package/build/public/style.css +106 -0
  59. package/build/utils/adb.d.ts +12 -0
  60. package/build/utils/adb.d.ts.map +1 -1
  61. package/build/utils/adb.js +116 -51
  62. package/build/utils/adb.js.map +1 -1
  63. package/build/utils/casting-apk.d.ts +40 -0
  64. package/build/utils/casting-apk.d.ts.map +1 -0
  65. package/build/utils/casting-apk.js +252 -0
  66. package/build/utils/casting-apk.js.map +1 -0
  67. package/build/utils/config.d.ts +5 -3
  68. package/build/utils/config.d.ts.map +1 -1
  69. package/build/utils/config.js +18 -38
  70. package/build/utils/config.js.map +1 -1
  71. package/build/utils/exec.d.ts +5 -0
  72. package/build/utils/exec.d.ts.map +1 -1
  73. package/build/utils/exec.js +17 -0
  74. package/build/utils/exec.js.map +1 -1
  75. package/build/utils/filename.d.ts +7 -1
  76. package/build/utils/filename.d.ts.map +1 -1
  77. package/build/utils/filename.js +17 -2
  78. package/build/utils/filename.js.map +1 -1
  79. package/build/utils/filename.test.js +33 -1
  80. package/build/utils/filename.test.js.map +1 -1
  81. package/build/utils/jpeg-comment.d.ts +14 -0
  82. package/build/utils/jpeg-comment.d.ts.map +1 -0
  83. package/build/utils/jpeg-comment.js +28 -0
  84. package/build/utils/jpeg-comment.js.map +1 -0
  85. package/build/utils/test-properties.d.ts +34 -0
  86. package/build/utils/test-properties.d.ts.map +1 -0
  87. package/build/utils/test-properties.js +73 -0
  88. package/build/utils/test-properties.js.map +1 -0
  89. package/build/utils/verbose.d.ts +3 -0
  90. package/build/utils/verbose.d.ts.map +1 -0
  91. package/build/utils/verbose.js +13 -0
  92. package/build/utils/verbose.js.map +1 -0
  93. package/package.json +11 -5
  94. package/packages/cast2-protocol/README.md +86 -0
  95. package/packages/cast2-protocol/docs/_config.yml +4 -0
  96. package/packages/cast2-protocol/docs/feature-flags.md +102 -0
  97. package/packages/cast2-protocol/docs/index.md +24 -0
  98. package/packages/cast2-protocol/docs/open-investigations.md +149 -0
  99. package/packages/cast2-protocol/docs/protocol.md +602 -0
  100. package/packages/cast2-protocol/package.json +46 -0
  101. package/packages/cast2-protocol/src/constants.ts +65 -0
  102. package/packages/cast2-protocol/src/index.ts +7 -0
  103. package/packages/cast2-protocol/src/mgik.ts +69 -0
  104. package/packages/cast2-protocol/src/mud.ts +294 -0
  105. package/packages/cast2-protocol/src/pose.ts +99 -0
  106. package/packages/cast2-protocol/src/resolutions.ts +34 -0
  107. package/packages/cast2-protocol/src/types.ts +64 -0
  108. package/packages/cast2-protocol/src/xrsp.ts +73 -0
  109. package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
  110. package/packages/cast2-protocol/tests/mud.test.ts +295 -0
  111. package/packages/cast2-protocol/tests/pose.test.ts +173 -0
  112. package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
  113. package/packages/cast2-protocol/tsconfig.json +20 -0
  114. package/pnpm-workspace.yaml +2 -0
  115. package/src/cast/decoder.ts +178 -0
  116. package/src/cast/session.ts +708 -0
  117. package/src/commands/logcat.ts +6 -5
  118. package/src/commands/open.ts +10 -3
  119. package/src/commands/screenshot.ts +19 -13
  120. package/src/commands/stay-awake.ts +22 -91
  121. package/src/daemon/adbkit-apkreader.d.ts +14 -0
  122. package/src/daemon/cast-manager.ts +282 -0
  123. package/src/daemon/client.ts +166 -0
  124. package/src/daemon/daemon.ts +169 -0
  125. package/src/daemon/deploy.ts +307 -0
  126. package/src/daemon/logcat-manager.ts +229 -0
  127. package/src/daemon/server.ts +595 -0
  128. package/src/daemon/stay-awake-manager.ts +83 -0
  129. package/src/index.ts +340 -56
  130. package/src/public/dashboard.js +288 -0
  131. package/src/public/index.html +12 -0
  132. package/src/public/style.css +106 -0
  133. package/src/utils/adb.ts +129 -42
  134. package/src/utils/casting-apk.ts +276 -0
  135. package/src/utils/config.ts +18 -36
  136. package/src/utils/exec.ts +20 -0
  137. package/src/utils/filename.test.ts +41 -1
  138. package/src/utils/filename.ts +18 -2
  139. package/src/utils/jpeg-comment.ts +30 -0
  140. package/src/utils/test-properties.ts +94 -0
  141. package/src/utils/verbose.ts +14 -0
  142. package/tests/cast/auto-layer.test.ts +87 -0
  143. package/tests/cast/decoder.test.ts +82 -0
  144. package/tests/cast/session-restart.test.ts +107 -0
  145. package/tests/config.test.ts +17 -22
  146. package/tests/daemon/api-status.test.ts +82 -0
  147. package/tests/daemon/cast-manager.test.ts +69 -0
  148. package/tests/daemon/mjpeg-stream.test.ts +144 -0
  149. package/tests/daemon/pose-endpoint.test.ts +63 -0
  150. package/tests/daemon/start-guard.test.ts +77 -0
  151. package/vitest.config.ts +10 -0
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect } from 'vitest';
6
- import { generateScreenshotFilename } from './filename.js';
6
+ import { generateScreenshotFilename, slugifyCaption } from './filename.js';
7
7
 
8
8
  describe('generateScreenshotFilename', () => {
9
9
  it('formats UTC timestamp correctly', () => {
@@ -24,6 +24,24 @@ describe('generateScreenshotFilename', () => {
24
24
  .toBe('screenshot-2026-12-31-00-00-00-Z.jpg');
25
25
  });
26
26
 
27
+ it('includes slugified caption in filename', () => {
28
+ const date = new Date('2026-01-15T14:30:45Z');
29
+ expect(generateScreenshotFilename(date, 'Main Menu Screenshot'))
30
+ .toBe('screenshot-2026-01-15-14-30-45-Z-main-menu-screenshot.jpg');
31
+ });
32
+
33
+ it('truncates long captions to 25 chars', () => {
34
+ const date = new Date('2026-01-15T14:30:45Z');
35
+ expect(generateScreenshotFilename(date, 'This is a very long caption that should be truncated'))
36
+ .toBe('screenshot-2026-01-15-14-30-45-Z-this-is-a-very-long-capti.jpg');
37
+ });
38
+
39
+ it('omits slug when no caption provided', () => {
40
+ const date = new Date('2026-01-15T14:30:45Z');
41
+ expect(generateScreenshotFilename(date))
42
+ .toBe('screenshot-2026-01-15-14-30-45-Z.jpg');
43
+ });
44
+
27
45
  it('generates filename with current time when no date provided', () => {
28
46
  const filename = generateScreenshotFilename();
29
47
 
@@ -53,3 +71,25 @@ describe('generateScreenshotFilename', () => {
53
71
  }
54
72
  });
55
73
  });
74
+
75
+ describe('slugifyCaption', () => {
76
+ it('lowercases and replaces spaces with dashes', () => {
77
+ expect(slugifyCaption('Main Menu')).toBe('main-menu');
78
+ });
79
+
80
+ it('replaces non-alphanumeric with dashes', () => {
81
+ expect(slugifyCaption('Hello, World! #1')).toBe('hello-world-1');
82
+ });
83
+
84
+ it('collapses consecutive dashes', () => {
85
+ expect(slugifyCaption('foo---bar')).toBe('foo-bar');
86
+ });
87
+
88
+ it('trims leading and trailing dashes', () => {
89
+ expect(slugifyCaption('--hello--')).toBe('hello');
90
+ });
91
+
92
+ it('truncates to 25 chars without trailing dash', () => {
93
+ expect(slugifyCaption('this is a very long caption that exceeds')).toBe('this-is-a-very-long-capti');
94
+ });
95
+ });
@@ -2,11 +2,25 @@
2
2
  * Filename generation utilities for screenshots
3
3
  */
4
4
 
5
+ /**
6
+ * Slugify a caption for use in filenames.
7
+ * Lowercase, replace non-alphanumeric with dashes, collapse runs, trim, truncate to 25 chars.
8
+ */
9
+ export function slugifyCaption(caption: string): string {
10
+ return caption
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-")
13
+ .replace(/^-|-$/g, "")
14
+ .slice(0, 25)
15
+ .replace(/-$/, "");
16
+ }
17
+
5
18
  /**
6
19
  * Generate UTC timestamp filename
7
20
  * Format: screenshot-YYYY-MM-DD-HH-MM-SS-Z.jpg
21
+ * With caption: screenshot-YYYY-MM-DD-HH-MM-SS-Z-slug.jpg
8
22
  */
9
- export function generateScreenshotFilename(date: Date = new Date()): string {
23
+ export function generateScreenshotFilename(date: Date = new Date(), caption?: string): string {
10
24
  const year = date.getUTCFullYear();
11
25
  const month = String(date.getUTCMonth() + 1).padStart(2, '0');
12
26
  const day = String(date.getUTCDate()).padStart(2, '0');
@@ -14,5 +28,7 @@ export function generateScreenshotFilename(date: Date = new Date()): string {
14
28
  const minutes = String(date.getUTCMinutes()).padStart(2, '0');
15
29
  const seconds = String(date.getUTCSeconds()).padStart(2, '0');
16
30
 
17
- return `screenshot-${year}-${month}-${day}-${hours}-${minutes}-${seconds}-Z.jpg`;
31
+ const base = `screenshot-${year}-${month}-${day}-${hours}-${minutes}-${seconds}-Z`;
32
+ const slug = caption ? slugifyCaption(caption) : "";
33
+ return slug ? `${base}-${slug}.jpg` : `${base}.jpg`;
18
34
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * JPEG COM marker insertion.
3
+ * Inserts a COM (0xFFFE) marker after the SOI marker without re-encoding.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync } from "fs";
7
+
8
+ /**
9
+ * Insert a COM comment marker into a JPEG buffer.
10
+ * Splices after the SOI (FF D8) marker — no re-encoding, no quality loss.
11
+ */
12
+ export function insertJpegComment(jpeg: Buffer, comment: string): Buffer {
13
+ if (jpeg[0] !== 0xff || jpeg[1] !== 0xd8) {
14
+ throw new Error("Not a valid JPEG (missing SOI marker)");
15
+ }
16
+ const payload = Buffer.from(comment, "utf-8");
17
+ const marker = Buffer.alloc(4);
18
+ marker.writeUInt16BE(0xfffe, 0);
19
+ marker.writeUInt16BE(payload.length + 2, 2);
20
+ return Buffer.concat([jpeg.subarray(0, 2), marker, payload, jpeg.subarray(2)]);
21
+ }
22
+
23
+ /**
24
+ * Add a COM comment to a JPEG file in-place.
25
+ */
26
+ export function addJpegFileComment(filePath: string, comment: string): void {
27
+ const jpeg = readFileSync(filePath);
28
+ const result = insertJpegComment(jpeg, comment);
29
+ writeFileSync(filePath, result);
30
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Meta Scriptable Testing API (content://com.oculus.rc) utilities.
3
+ *
4
+ * Shared between stay-awake and cast commands for managing test mode
5
+ * properties (guardian, autosleep, dialogs, proximity).
6
+ */
7
+
8
+ import { execCommand, execCommandFull } from "./exec.js";
9
+ import { adbArgs } from "./adb.js";
10
+
11
+ export interface TestProperties {
12
+ disable_guardian: boolean;
13
+ disable_dialogs: boolean;
14
+ disable_autosleep: boolean;
15
+ set_proximity_close: boolean;
16
+ }
17
+
18
+ /**
19
+ * Build ADB args for SET_PROPERTY call.
20
+ */
21
+ export function buildSetPropertyArgs(pin: string, enabled: boolean): string[] {
22
+ return [
23
+ "shell", "content", "call",
24
+ "--uri", "content://com.oculus.rc",
25
+ "--method", "SET_PROPERTY",
26
+ "--extra", `disable_guardian:b:${enabled}`,
27
+ "--extra", `disable_dialogs:b:${enabled}`,
28
+ "--extra", `disable_autosleep:b:${enabled}`,
29
+ "--extra", `set_proximity_close:b:${enabled}`,
30
+ "--extra", `PIN:s:${pin}`,
31
+ ];
32
+ }
33
+
34
+ /**
35
+ * Parse GET_PROPERTY Bundle output into structured data.
36
+ * Input: "Bundle[{disable_guardian=true, set_proximity_close=true, ...}]"
37
+ */
38
+ export function parseTestProperties(output: string): TestProperties {
39
+ const defaults: TestProperties = {
40
+ disable_guardian: false,
41
+ disable_dialogs: false,
42
+ disable_autosleep: false,
43
+ set_proximity_close: false,
44
+ };
45
+
46
+ const match = output.match(/Bundle\[\{(.+)\}\]/);
47
+ if (!match) return defaults;
48
+
49
+ const pairs = match[1].split(",").map((s) => s.trim());
50
+ for (const pair of pairs) {
51
+ const [key, value] = pair.split("=");
52
+ if (key && value && key in defaults) {
53
+ defaults[key as keyof TestProperties] = value === "true";
54
+ }
55
+ }
56
+
57
+ return defaults;
58
+ }
59
+
60
+ /**
61
+ * Call SET_PROPERTY to enable or disable test mode.
62
+ */
63
+ export async function setTestProperties(
64
+ pin: string,
65
+ enabled: boolean,
66
+ ): Promise<void> {
67
+ const args = adbArgs(...buildSetPropertyArgs(pin, enabled));
68
+ await execCommand("adb", args);
69
+ }
70
+
71
+ /**
72
+ * Call GET_PROPERTY and return parsed test properties.
73
+ */
74
+ export async function getTestProperties(): Promise<TestProperties> {
75
+ const result = await execCommandFull("adb", adbArgs(
76
+ "shell", "content", "call",
77
+ "--uri", "content://com.oculus.rc",
78
+ "--method", "GET_PROPERTY",
79
+ ));
80
+ return parseTestProperties(result.stdout);
81
+ }
82
+
83
+ /**
84
+ * Format test properties for display.
85
+ */
86
+ export function formatTestProperties(props: TestProperties): string {
87
+ const lines = [
88
+ ` Guardian disabled: ${props.disable_guardian}`,
89
+ ` Dialogs disabled: ${props.disable_dialogs}`,
90
+ ` Autosleep disabled: ${props.disable_autosleep}`,
91
+ ` Proximity close: ${props.set_proximity_close}`,
92
+ ];
93
+ return lines.join("\n");
94
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Global verbose flag for debug output
3
+ */
4
+ let _verbose = false;
5
+
6
+ export function setVerbose(v: boolean): void {
7
+ _verbose = v;
8
+ }
9
+
10
+ export function verbose(...args: unknown[]): void {
11
+ if (_verbose) {
12
+ console.log('[verbose]', ...args);
13
+ }
14
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ /**
4
+ * Auto-layer selection logic extracted from CastSession.
5
+ * Bug: when layer.id === 0 and is PANEL_APP, the condition
6
+ * `this._layerId === 0` is true, so it "selects" layer 0
7
+ * which is a no-op (already 0). It should instead track
8
+ * whether a layer was ever explicitly selected.
9
+ */
10
+
11
+ const LAYER_PANEL_APP = 0;
12
+ const LAYER_EYE_BUFFER = 1;
13
+
14
+ interface Layer {
15
+ id: number;
16
+ type: number;
17
+ }
18
+
19
+ function autoSelectLayer(
20
+ layers: Layer[],
21
+ currentLayerId: number,
22
+ hasAutoSelected: boolean,
23
+ ): { layerId: number; hasAutoSelected: boolean } {
24
+ let layerId = currentLayerId;
25
+ let selected = hasAutoSelected;
26
+ for (const layer of layers) {
27
+ if (layer.type === LAYER_PANEL_APP && !selected) {
28
+ layerId = layer.id;
29
+ selected = true;
30
+ }
31
+ }
32
+ return { layerId, hasAutoSelected: selected };
33
+ }
34
+
35
+ describe("auto-layer selection", () => {
36
+ it("selects first PANEL_APP layer", () => {
37
+ const result = autoSelectLayer(
38
+ [{ id: 5, type: LAYER_PANEL_APP }],
39
+ 0,
40
+ false,
41
+ );
42
+ expect(result.layerId).toBe(5);
43
+ expect(result.hasAutoSelected).toBe(true);
44
+ });
45
+
46
+ it("selects PANEL_APP with id 0", () => {
47
+ const result = autoSelectLayer(
48
+ [{ id: 0, type: LAYER_PANEL_APP }],
49
+ 0,
50
+ false,
51
+ );
52
+ // Should still mark as selected even when id is 0
53
+ expect(result.layerId).toBe(0);
54
+ expect(result.hasAutoSelected).toBe(true);
55
+ });
56
+
57
+ it("does not re-select after first auto-selection", () => {
58
+ const result = autoSelectLayer(
59
+ [
60
+ { id: 5, type: LAYER_PANEL_APP },
61
+ { id: 9, type: LAYER_PANEL_APP },
62
+ ],
63
+ 0,
64
+ false,
65
+ );
66
+ expect(result.layerId).toBe(5); // first one wins
67
+ });
68
+
69
+ it("does not override manual selection", () => {
70
+ const result = autoSelectLayer(
71
+ [{ id: 5, type: LAYER_PANEL_APP }],
72
+ 3, // manually set
73
+ true, // already selected
74
+ );
75
+ expect(result.layerId).toBe(3); // unchanged
76
+ });
77
+
78
+ it("ignores non-PANEL_APP layers", () => {
79
+ const result = autoSelectLayer(
80
+ [{ id: 2, type: LAYER_EYE_BUFFER }],
81
+ 0,
82
+ false,
83
+ );
84
+ expect(result.layerId).toBe(0);
85
+ expect(result.hasAutoSelected).toBe(false);
86
+ });
87
+ });
@@ -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
+ });