@myerscarpenter/quest-dev 1.0.8 → 1.2.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 (45) hide show
  1. package/build/commands/battery.d.ts +9 -0
  2. package/build/commands/battery.d.ts.map +1 -0
  3. package/build/commands/battery.js +31 -0
  4. package/build/commands/battery.js.map +1 -0
  5. package/build/commands/logcat.d.ts +24 -0
  6. package/build/commands/logcat.d.ts.map +1 -0
  7. package/build/commands/logcat.js +261 -0
  8. package/build/commands/logcat.js.map +1 -0
  9. package/build/commands/open.d.ts +1 -1
  10. package/build/commands/open.d.ts.map +1 -1
  11. package/build/commands/open.js +14 -14
  12. package/build/commands/open.js.map +1 -1
  13. package/build/commands/screenshot.d.ts +1 -1
  14. package/build/commands/screenshot.d.ts.map +1 -1
  15. package/build/commands/screenshot.js +55 -9
  16. package/build/commands/screenshot.js.map +1 -1
  17. package/build/commands/stay-awake.d.ts +14 -0
  18. package/build/commands/stay-awake.d.ts.map +1 -0
  19. package/build/commands/stay-awake.js +234 -0
  20. package/build/commands/stay-awake.js.map +1 -0
  21. package/build/index.js +84 -5
  22. package/build/index.js.map +1 -1
  23. package/build/utils/adb.d.ts +12 -7
  24. package/build/utils/adb.d.ts.map +1 -1
  25. package/build/utils/adb.js +138 -31
  26. package/build/utils/adb.js.map +1 -1
  27. package/build/utils/filename.d.ts +9 -0
  28. package/build/utils/filename.d.ts.map +1 -0
  29. package/build/utils/filename.js +17 -0
  30. package/build/utils/filename.js.map +1 -0
  31. package/build/utils/filename.test.d.ts +5 -0
  32. package/build/utils/filename.test.d.ts.map +1 -0
  33. package/build/utils/filename.test.js +40 -0
  34. package/build/utils/filename.test.js.map +1 -0
  35. package/package.json +2 -1
  36. package/src/commands/battery.ts +34 -0
  37. package/src/commands/logcat.ts +288 -0
  38. package/src/commands/open.ts +18 -14
  39. package/src/commands/screenshot.ts +61 -9
  40. package/src/commands/stay-awake.ts +254 -0
  41. package/src/index.ts +119 -9
  42. package/src/utils/adb.ts +148 -31
  43. package/src/utils/filename.test.ts +55 -0
  44. package/src/utils/filename.ts +18 -0
  45. package/tests/adb.test.ts +2 -2
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Quest logcat command
3
+ * Captures Android logcat to files for Quest debugging
4
+ *
5
+ * CRITICAL: Quest's ring buffer fills in seconds under VR load.
6
+ * Always capture to a file BEFORE testing to avoid losing crash logs.
7
+ */
8
+
9
+ import { resolve, join } from 'path';
10
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, symlinkSync, statSync, readlinkSync, openSync } from 'fs';
11
+ import { spawn } from 'child_process';
12
+ import { checkADBPath, checkADBDevices } from '../utils/adb.js';
13
+ import { execCommand, execCommandFull } from '../utils/exec.js';
14
+
15
+ const LOG_DIR = process.env.LOG_DIR || 'logs/logcat';
16
+ const PID_FILE = join(LOG_DIR, '.logcat_pid');
17
+ const LOGFILE_LINK = join(LOG_DIR, 'latest.txt');
18
+
19
+ /**
20
+ * Ensure log directory exists
21
+ */
22
+ function ensureLogDir(): void {
23
+ if (!existsSync(LOG_DIR)) {
24
+ mkdirSync(LOG_DIR, { recursive: true });
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Check if a process is running by PID
30
+ */
31
+ function isProcessRunning(pid: number): boolean {
32
+ try {
33
+ // Sending signal 0 checks if process exists without killing it
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Read PID from PID file
43
+ */
44
+ function readPidFile(): number | null {
45
+ if (!existsSync(PID_FILE)) {
46
+ return null;
47
+ }
48
+ try {
49
+ const pidStr = readFileSync(PID_FILE, 'utf-8').trim();
50
+ return parseInt(pidStr, 10);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Write PID to PID file
58
+ */
59
+ function writePidFile(pid: number): void {
60
+ writeFileSync(PID_FILE, pid.toString(), 'utf-8');
61
+ }
62
+
63
+ /**
64
+ * Delete PID file
65
+ */
66
+ function deletePidFile(): void {
67
+ if (existsSync(PID_FILE)) {
68
+ unlinkSync(PID_FILE);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Get the latest log file path
74
+ */
75
+ function getLatestLogFile(): string | null {
76
+ if (!existsSync(LOGFILE_LINK)) {
77
+ return null;
78
+ }
79
+ try {
80
+ const target = readlinkSync(LOGFILE_LINK);
81
+ const fullPath = join(LOG_DIR, target);
82
+ if (existsSync(fullPath)) {
83
+ return fullPath;
84
+ }
85
+ } catch {
86
+ // Symlink might be broken
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Get file size and line count
93
+ */
94
+ function getFileStats(filePath: string): { size: string; lines: number } | null {
95
+ try {
96
+ const stats = statSync(filePath);
97
+ const sizeInBytes = stats.size;
98
+ let sizeStr: string;
99
+
100
+ if (sizeInBytes < 1024) {
101
+ sizeStr = `${sizeInBytes}B`;
102
+ } else if (sizeInBytes < 1024 * 1024) {
103
+ sizeStr = `${(sizeInBytes / 1024).toFixed(1)}K`;
104
+ } else {
105
+ sizeStr = `${(sizeInBytes / (1024 * 1024)).toFixed(1)}M`;
106
+ }
107
+
108
+ const content = readFileSync(filePath, 'utf-8');
109
+ const lines = content.split('\n').length;
110
+
111
+ return { size: sizeStr, lines };
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Start logcat capture
119
+ */
120
+ export async function startCommand(filter?: string): Promise<void> {
121
+ // Check for existing capture
122
+ const existingPid = readPidFile();
123
+ if (existingPid && isProcessRunning(existingPid)) {
124
+ console.error('Already capturing. Use "quest-dev logcat stop" first.');
125
+ process.exit(1);
126
+ }
127
+
128
+ // Check prerequisites
129
+ checkADBPath();
130
+ await checkADBDevices();
131
+
132
+ ensureLogDir();
133
+
134
+ // Generate log filename
135
+ const timestamp = new Date().toISOString()
136
+ .replace(/[-:]/g, '')
137
+ .replace(/\..+/, '')
138
+ .replace('T', '_')
139
+ .slice(0, 15); // YYYYMMDD_HHMMSS
140
+ const logFile = join(LOG_DIR, `logcat_${timestamp}.txt`);
141
+
142
+ console.log(`Starting capture to: ${logFile}`);
143
+
144
+ // Clear the buffer first - critical for Quest
145
+ try {
146
+ await execCommand('adb', ['logcat', '-c']);
147
+ console.log('Ring buffer cleared.');
148
+ } catch (error) {
149
+ console.error('Failed to clear ring buffer:', (error as Error).message);
150
+ process.exit(1);
151
+ }
152
+
153
+ if (filter) {
154
+ console.log(`Filter: ${filter}`);
155
+ }
156
+
157
+ // Start background logcat process
158
+ const args = ['logcat', '-v', 'threadtime'];
159
+ if (filter) {
160
+ args.push(filter);
161
+ }
162
+
163
+ // Open file for writing
164
+ const fd = openSync(logFile, 'w');
165
+
166
+ const proc = spawn('adb', args, {
167
+ stdio: ['ignore', fd, fd],
168
+ detached: true
169
+ });
170
+
171
+ // Unref so parent can exit immediately
172
+ proc.unref();
173
+
174
+ // Save PID
175
+ writePidFile(proc.pid!);
176
+
177
+ // Update symlink
178
+ try {
179
+ if (existsSync(LOGFILE_LINK)) {
180
+ unlinkSync(LOGFILE_LINK);
181
+ }
182
+ symlinkSync(`logcat_${timestamp}.txt`, LOGFILE_LINK);
183
+ } catch (error) {
184
+ console.warn('Warning: Failed to create symlink:', (error as Error).message);
185
+ }
186
+
187
+ console.log(`Capturing (PID: ${proc.pid})`);
188
+ console.log('');
189
+ console.log('Now run your test. When done: quest-dev logcat stop');
190
+ }
191
+
192
+ /**
193
+ * Stop logcat capture
194
+ */
195
+ export async function stopCommand(): Promise<void> {
196
+ const pid = readPidFile();
197
+
198
+ if (!pid) {
199
+ console.log('No capture in progress');
200
+ return;
201
+ }
202
+
203
+ if (isProcessRunning(pid)) {
204
+ try {
205
+ process.kill(pid, 'SIGTERM');
206
+ console.log(`Capture stopped (PID: ${pid})`);
207
+ } catch (error) {
208
+ console.log(`Capture process already ended (PID: ${pid})`);
209
+ }
210
+ } else {
211
+ console.log('Capture process already ended');
212
+ }
213
+
214
+ deletePidFile();
215
+
216
+ // Show file info
217
+ const latestFile = getLatestLogFile();
218
+ if (latestFile) {
219
+ const stats = getFileStats(latestFile);
220
+ if (stats) {
221
+ console.log('');
222
+ console.log(`Log file: ${latestFile}`);
223
+ console.log(`Size: ${stats.size} (${stats.lines} lines)`);
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Show capture status
230
+ */
231
+ export async function statusCommand(): Promise<void> {
232
+ const pid = readPidFile();
233
+
234
+ if (pid && isProcessRunning(pid)) {
235
+ console.log(`Capturing (PID: ${pid})`);
236
+
237
+ const latestFile = getLatestLogFile();
238
+ if (latestFile) {
239
+ const stats = getFileStats(latestFile);
240
+ if (stats) {
241
+ console.log(`File: ${latestFile}`);
242
+ console.log(`Size: ${stats.size} (${stats.lines} lines)`);
243
+ }
244
+ }
245
+ } else {
246
+ console.log('Not capturing');
247
+
248
+ // Show recent logs
249
+ if (existsSync(LOG_DIR)) {
250
+ console.log('');
251
+ console.log('Recent logs:');
252
+ try {
253
+ const result = await execCommandFull('ls', ['-lht', join(LOG_DIR, '*.txt')]);
254
+ if (result.code === 0) {
255
+ const lines = result.stdout.trim().split('\n').slice(0, 5);
256
+ lines.forEach(line => console.log(' ' + line));
257
+ }
258
+ } catch {
259
+ // Ignore
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Tail current capture
267
+ */
268
+ export async function tailCommand(): Promise<void> {
269
+ const latestFile = getLatestLogFile();
270
+
271
+ if (!latestFile) {
272
+ console.error('No active log file');
273
+ process.exit(1);
274
+ }
275
+
276
+ console.log(`Tailing: ${latestFile}`);
277
+ console.log('Press Ctrl+C to stop\n');
278
+
279
+ // Use tail -f
280
+ const tailProc = spawn('tail', ['-f', latestFile], {
281
+ stdio: 'inherit'
282
+ });
283
+
284
+ tailProc.on('error', (error) => {
285
+ console.error('Failed to tail log:', error.message);
286
+ process.exit(1);
287
+ });
288
+ }
@@ -17,8 +17,8 @@ import { execCommand, execCommandFull } from '../utils/exec.js';
17
17
  /**
18
18
  * Close all tabs except the one with the target URL
19
19
  */
20
- async function closeOtherTabs(targetUrl: string): Promise<void> {
21
- const cdpPort = getCDPPort();
20
+ async function closeOtherTabs(targetUrl: string, browser: string): Promise<void> {
21
+ const cdpPort = await getCDPPort(browser);
22
22
 
23
23
  try {
24
24
  // Get list of tabs
@@ -66,8 +66,8 @@ async function closeOtherTabs(targetUrl: string): Promise<void> {
66
66
  /**
67
67
  * Try to navigate or reload existing tab via cdp-cli
68
68
  */
69
- async function tryNavigateExistingTab(targetUrl: string): Promise<boolean> {
70
- const cdpPort = getCDPPort();
69
+ async function tryNavigateExistingTab(targetUrl: string, browser: string): Promise<boolean> {
70
+ const cdpPort = await getCDPPort(browser);
71
71
 
72
72
  try {
73
73
  // Get list of tabs using cdp-cli
@@ -142,7 +142,11 @@ async function tryNavigateExistingTab(targetUrl: string): Promise<boolean> {
142
142
  /**
143
143
  * Main open command handler
144
144
  */
145
- export async function openCommand(url: string, closeOthers: boolean = false): Promise<void> {
145
+ export async function openCommand(
146
+ url: string,
147
+ closeOthers: boolean = false,
148
+ browser: string = 'com.oculus.browser'
149
+ ): Promise<void> {
146
150
  // Parse URL to determine if we need reverse port forwarding
147
151
  let parsedUrl: URL;
148
152
  try {
@@ -175,27 +179,27 @@ export async function openCommand(url: string, closeOthers: boolean = false): Pr
175
179
  // Set up port forwarding
176
180
  if (port !== null) {
177
181
  // Localhost URL: need reverse forwarding so Quest can reach the dev server
178
- await ensurePortForwarding(port);
182
+ await ensurePortForwarding(port, browser);
179
183
  } else {
180
184
  // External URL: only need CDP forwarding to control the browser
181
- await ensureCDPForwarding();
185
+ await ensureCDPForwarding(browser);
182
186
  }
183
187
 
184
188
  // Check if browser is running
185
- const browserRunning = await isBrowserRunning();
189
+ const browserRunning = await isBrowserRunning(browser);
186
190
 
187
191
  if (!browserRunning) {
188
- console.log('Quest browser is not running');
189
- await launchBrowser(url);
192
+ console.log('Browser is not running');
193
+ await launchBrowser(url, browser);
190
194
  } else {
191
- console.log('Quest browser is already running');
195
+ console.log('Browser is already running');
192
196
 
193
197
  // Try to navigate existing or blank tab via cdp-cli first
194
- const navigated = await tryNavigateExistingTab(url);
198
+ const navigated = await tryNavigateExistingTab(url, browser);
195
199
 
196
200
  if (!navigated) {
197
201
  console.log('No existing or blank tab found, opening URL...');
198
- await launchBrowser(url);
202
+ await launchBrowser(url, browser);
199
203
  }
200
204
  }
201
205
 
@@ -204,7 +208,7 @@ export async function openCommand(url: string, closeOthers: boolean = false): Pr
204
208
  // Wait for browser to stabilize after launch/navigation
205
209
  console.log('Waiting for browser to stabilize...');
206
210
  await new Promise(resolve => setTimeout(resolve, 2000));
207
- await closeOtherTabs(url);
211
+ await closeOtherTabs(url, browser);
208
212
  }
209
213
 
210
214
  console.log('\nDone!\n');
@@ -3,9 +3,33 @@
3
3
  * Triggers Quest's native screenshot service and pulls the file
4
4
  */
5
5
 
6
- import { resolve } from 'path';
6
+ import { resolve, join } from 'path';
7
+ import { existsSync, statSync } from 'fs';
7
8
  import { checkADBPath, checkADBDevices, checkUSBFileTransfer, checkQuestAwake } from '../utils/adb.js';
8
9
  import { execCommand, execCommandFull } from '../utils/exec.js';
10
+ import { generateScreenshotFilename } from '../utils/filename.js';
11
+
12
+ /**
13
+ * Validate directory exists and is writable
14
+ */
15
+ function validateDirectory(dirPath: string): void {
16
+ const resolvedPath = resolve(dirPath);
17
+
18
+ if (!existsSync(resolvedPath)) {
19
+ console.error(`Error: Directory does not exist: ${resolvedPath}`);
20
+ console.error('');
21
+ console.error('Please create the directory first.');
22
+ console.error('');
23
+ process.exit(1);
24
+ }
25
+
26
+ const stat = statSync(resolvedPath);
27
+ if (!stat.isDirectory()) {
28
+ console.error(`Error: Path is not a directory: ${resolvedPath}`);
29
+ console.error('');
30
+ process.exit(1);
31
+ }
32
+ }
9
33
 
10
34
  /**
11
35
  * Trigger Quest screenshot service
@@ -106,14 +130,37 @@ async function deleteRemoteScreenshot(filename: string): Promise<void> {
106
130
  }
107
131
  }
108
132
 
133
+ /**
134
+ * Add caption to JPEG COM metadata
135
+ */
136
+ async function addJpegMetadata(filePath: string, caption: string): Promise<boolean> {
137
+ try {
138
+ const { exiftool } = await import('exiftool-vendored');
139
+ await exiftool.write(filePath, { Comment: caption });
140
+ await exiftool.end();
141
+ console.log(`Caption added: "${caption}"`);
142
+ return true;
143
+ } catch (error) {
144
+ console.error('Warning: Failed to add caption metadata:', (error as Error).message);
145
+ return false;
146
+ }
147
+ }
148
+
109
149
  /**
110
150
  * Main screenshot command handler
111
151
  */
112
- export async function screenshotCommand(outputPath: string): Promise<void> {
113
- const resolvedPath = resolve(outputPath);
152
+ export async function screenshotCommand(directoryPath: string, caption: string | undefined): Promise<void> {
153
+ const resolvedDir = resolve(directoryPath);
114
154
 
115
155
  console.log('\nQuest Screenshot\n');
116
156
 
157
+ // Validate directory (fail-fast before expensive ADB ops)
158
+ validateDirectory(resolvedDir);
159
+
160
+ // Generate filename
161
+ const localFilename = generateScreenshotFilename();
162
+ const outputPath = join(resolvedDir, localFilename);
163
+
117
164
  // Check prerequisites
118
165
  checkADBPath();
119
166
  await checkADBDevices();
@@ -130,7 +177,7 @@ export async function screenshotCommand(outputPath: string): Promise<void> {
130
177
 
131
178
  // Wait for screenshot to save and verify it's complete (has JPEG EOI marker)
132
179
  console.log('Waiting for screenshot to save...');
133
- let filename: string | null = null;
180
+ let questFilename: string | null = null;
134
181
  const maxAttempts = 20;
135
182
 
136
183
  for (let i = 0; i < maxAttempts; i++) {
@@ -141,14 +188,14 @@ export async function screenshotCommand(outputPath: string): Promise<void> {
141
188
  // Check that the JPEG is fully written (has EOI marker)
142
189
  const complete = await isJpegComplete(newScreenshot);
143
190
  if (complete) {
144
- filename = newScreenshot;
145
- console.log(`Screenshot ready: ${filename}`);
191
+ questFilename = newScreenshot;
192
+ console.log(`Screenshot ready: ${questFilename}`);
146
193
  break;
147
194
  }
148
195
  }
149
196
  }
150
197
 
151
- if (!filename) {
198
+ if (!questFilename) {
152
199
  console.error('Error: Screenshot was not created or is incomplete');
153
200
  console.error('');
154
201
  console.error('The screenshot service was triggered but no valid screenshot appeared.');
@@ -162,12 +209,17 @@ export async function screenshotCommand(outputPath: string): Promise<void> {
162
209
  }
163
210
 
164
211
  // Pull screenshot
165
- if (!await pullScreenshot(filename, resolvedPath)) {
212
+ if (!await pullScreenshot(questFilename, outputPath)) {
166
213
  process.exit(1);
167
214
  }
168
215
 
216
+ // Add metadata (non-fatal, only if caption provided)
217
+ if (caption) {
218
+ await addJpegMetadata(outputPath, caption);
219
+ }
220
+
169
221
  // Delete from Quest after successful pull
170
- await deleteRemoteScreenshot(filename);
222
+ await deleteRemoteScreenshot(questFilename);
171
223
 
172
224
  console.log('\nDone!\n');
173
225
  }