@loadmill/droid-cua 2.13.0 → 2.15.1

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/README.md CHANGED
@@ -142,6 +142,9 @@ droid-cua
142
142
  # Headless Android run
143
143
  droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua
144
144
 
145
+ # Headless Android folder run
146
+ droid-cua --avd adb:emulator-5554 --instructions tests
147
+
145
148
  # Headless iOS simulator run
146
149
  droid-cua --platform ios --avd "iPhone 16" --instructions tests/login.dcua
147
150
 
package/build/index.js CHANGED
@@ -3,14 +3,12 @@ import dotenv from "dotenv";
3
3
  import path from "path";
4
4
  import { mkdir } from "fs/promises";
5
5
  import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo } from "./src/device/connection.js";
6
- import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "./src/browser/connection.js";
7
- import { handleModelAction as handleBrowserModelAction } from "./src/browser/actions.js";
6
+ import { connectToBrowser, getBrowserInfo, } from "./src/browser/connection.js";
8
7
  import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
9
8
  import { Session } from "./src/core/session.js";
10
9
  import { ExecutionEngine } from "./src/core/execution-engine.js";
11
- import { buildBaseSystemPrompt, buildExecutionModePrompt } from "./src/core/prompts.js";
10
+ import { buildBaseSystemPrompt } from "./src/core/prompts.js";
12
11
  import { startInkShell } from "./src/cli/ink-shell.js";
13
- import { ExecutionMode } from "./src/modes/execution-mode.js";
14
12
  import { createOpenAiExecutor } from "./src/device/openai.js";
15
13
  import { logger } from "./src/utils/logger.js";
16
14
  import { selectDevice } from "./src/cli/device-selector.js";
@@ -18,10 +16,10 @@ import { resolveExecutionAppContext } from "./src/core/app-context.js";
18
16
  import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
19
17
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
20
18
  import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
21
- import { printCliOutput } from "./src/utils/console-output.js";
22
- import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
23
19
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
24
- import { loadHeadlessInstructions, resolveHeadlessInstructionsPath } from "./src/cli/headless-instructions.js";
20
+ import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
21
+ import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
22
+ import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
25
23
  import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
26
24
  import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
27
25
  import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
@@ -109,13 +107,30 @@ async function main() {
109
107
  throw new Error("--device-source lambdatest is only supported with --instructions headless execution.");
110
108
  }
111
109
  let preloadedHeadlessConfig = null;
112
- let preloadedHeadlessInstructions = null;
113
- let resolvedInstructionsFile = instructionsFile;
110
+ let preloadedHeadlessTarget = null;
111
+ let headlessInstructionRuns = [];
114
112
  let preloadedAppContext = null;
115
113
  if (isHeadlessInstructionsRun) {
116
114
  preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
117
- resolvedInstructionsFile = await resolveHeadlessInstructionsPath(instructionsFile);
118
- preloadedHeadlessInstructions = await loadHeadlessInstructions(resolvedInstructionsFile);
115
+ preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
116
+ if (preloadedHeadlessTarget.type === "directory") {
117
+ const files = await listHeadlessInstructionFiles(preloadedHeadlessTarget.path);
118
+ if (files.length === 0) {
119
+ throw new Error(`No .dcua tests found in instructions directory ${preloadedHeadlessTarget.path}.`);
120
+ }
121
+ headlessInstructionRuns = files.map((file) => ({
122
+ testPath: file.path,
123
+ testName: file.filename,
124
+ instructions: null
125
+ }));
126
+ }
127
+ else {
128
+ headlessInstructionRuns = [{
129
+ testPath: preloadedHeadlessTarget.path,
130
+ testName: path.basename(preloadedHeadlessTarget.path),
131
+ instructions: await loadHeadlessInstructions(preloadedHeadlessTarget.path)
132
+ }];
133
+ }
119
134
  preloadedAppContext = await resolveExecutionAppContext({
120
135
  enabled: preloadedHeadlessConfig.appContextEnabled,
121
136
  contextPath: preloadedHeadlessConfig.appContextPath,
@@ -198,25 +213,9 @@ async function main() {
198
213
  session.setSystemPrompt(initialSystemText);
199
214
  // If --instructions provided, run in headless mode
200
215
  if (instructionsFile) {
201
- const runId = `run-${Date.now()}`;
202
- const testName = path.basename(resolvedInstructionsFile);
203
- let instructions = [];
204
- let executionMode = null;
205
216
  let exitCode = null;
206
217
  try {
207
218
  const headlessConfig = preloadedHeadlessConfig;
208
- await headlessDebug.startExecutionSession(runId, {
209
- testName,
210
- platform: deviceInfo.platform,
211
- deviceName: deviceInfo.device_name,
212
- aimAssistMode: headlessConfig.aimAssistMode
213
- });
214
- const currentLogFilePath = await headlessDebug.getCurrentLogFilePath();
215
- if (currentLogFilePath) {
216
- console.log(`Debug logging enabled: ${currentLogFilePath}`);
217
- }
218
- console.log(`\nRunning test from: ${resolvedInstructionsFile}\n`);
219
- instructions = preloadedHeadlessInstructions;
220
219
  const appContext = preloadedAppContext;
221
220
  const appContextText = appContext.text;
222
221
  const runtimeContext = createCliRuntimeContext({
@@ -232,130 +231,21 @@ async function main() {
232
231
  message: appContext.message,
233
232
  },
234
233
  });
235
- console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
236
- if (appContext.status === "loaded" || appContext.status === "empty") {
237
- emitDesktopDebug("app_context.loaded", "execution", { runId }, {
238
- source: "cli_context_flag",
239
- contextPath: appContext.contextPath,
240
- status: appContext.status,
241
- text: appContextText
242
- });
243
- if (appContextText) {
244
- console.log(`Using app context from: ${appContext.contextPath}`);
245
- }
246
- }
247
- else if (appContext.status === "failed") {
248
- emitDesktopDebug("app_context.loaded", "execution", { runId }, {
249
- source: "cli_context_flag",
250
- contextPath: appContext.contextPath,
251
- status: "failed",
252
- message: appContext.message
253
- });
254
- console.warn(`Warning: could not load app context from ${appContext.contextPath}. Running without it.`);
255
- }
256
- else {
257
- emitDesktopDebug("app_context.loaded", "execution", { runId }, {
258
- source: "cli_context_flag",
259
- contextPath: appContext.contextPath,
260
- status: appContext.status
261
- });
262
- }
263
- const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, { aimAssistMode: headlessConfig.aimAssistMode });
264
- session.setSystemPrompt(executionPrompt);
265
- const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
266
- runId,
234
+ const result = await runHeadlessInstructionRuns({
235
+ runs: headlessInstructionRuns,
236
+ instructionsTarget: preloadedHeadlessTarget,
237
+ session,
238
+ deviceInfo,
239
+ headlessConfig,
240
+ appContext,
241
+ runtimeContext,
242
+ headlessDebug,
267
243
  recordScreenshots,
268
- screenshotDir
269
- });
270
- const executionEngineOptions = {
271
- toolCapabilities: {
272
- assert: true,
273
- navigate: isBrowserTarget,
274
- loadmill: true,
275
- },
276
- baseSystemPrompt: executionPrompt,
277
- isHeadlessMode: true,
278
- };
279
- const engineOptions = screenshotRecorder
280
- ? {
281
- recordScreenshots: true,
282
- screenshotRecorder,
283
- stepDelayMs: runtimeContext.config.stepDelayMs,
284
- sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
285
- ...requestArgs,
286
- aimAssistMode: headlessConfig.aimAssistMode,
287
- }),
288
- reviseTestScript: runtimeContext.openAi.reviseTestScript,
289
- ...executionEngineOptions,
290
- }
291
- : {
292
- recordScreenshots,
293
- screenshotDir,
294
- stepDelayMs: runtimeContext.config.stepDelayMs,
295
- sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
296
- ...requestArgs,
297
- aimAssistMode: headlessConfig.aimAssistMode,
298
- }),
299
- reviseTestScript: runtimeContext.openAi.reviseTestScript,
300
- ...executionEngineOptions,
301
- };
302
- if (isBrowserTarget) {
303
- engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
304
- engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
305
- engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
306
- }
307
- else if (isLambdaTestTarget) {
308
- engineOptions.connectToDevice = async () => {
309
- throw new Error("LambdaTest cloud session disconnected; automatic CLI reconnect is not supported yet.");
310
- };
311
- }
312
- const engine = new ExecutionEngine(session, engineOptions);
313
- executionMode = new ExecutionMode(session, engine, instructions, true);
314
- const result = await executionMode.execute({
315
- runId,
316
- addOutput: printCliOutput,
317
- openAiClient: runtimeContext.openAiClient,
318
- loadmillClient: runtimeContext.loadmillClient,
319
- runtimeCredentials: runtimeContext.credentials,
320
- });
321
- const stats = executionMode.stats || {};
322
- const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
323
- await headlessDebug.endExecutionSession(runId, {
324
- success: Boolean(result.success),
325
- error: result.error ?? null,
326
- durationMs,
327
- instructionsTotal: instructions.length,
328
- instructionsCompleted: stats.instructionsCompleted ?? 0,
329
- actionsTotal: stats.actionCount ?? 0,
330
- assertionsPassed: stats.assertionsPassed ?? 0,
331
- assertionsFailed: stats.assertionsFailed ?? 0,
332
- retries: stats.retryCount ?? 0
244
+ screenshotDir,
245
+ isBrowserTarget,
246
+ isLambdaTestTarget
333
247
  });
334
- if (!result.success) {
335
- console.error(`\nTest failed: ${result.error}`);
336
- }
337
- exitCode = result.success ? 0 : 1;
338
- }
339
- catch (error) {
340
- const message = error instanceof Error ? error.message : "Failed to start execution.";
341
- const stats = executionMode?.stats || {};
342
- const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
343
- await headlessDebug.endExecutionSession(runId, {
344
- success: false,
345
- error: message,
346
- ...(stats.startTime
347
- ? {
348
- durationMs,
349
- instructionsTotal: instructions.length,
350
- instructionsCompleted: stats.instructionsCompleted ?? 0,
351
- actionsTotal: stats.actionCount ?? 0,
352
- assertionsPassed: stats.assertionsPassed ?? 0,
353
- assertionsFailed: stats.assertionsFailed ?? 0,
354
- retries: stats.retryCount ?? 0
355
- }
356
- : { reason: "start_failed" })
357
- });
358
- throw error;
248
+ exitCode = result.exitCode;
359
249
  }
360
250
  finally {
361
251
  if (lambdaTestSessionConnected) {
@@ -28,6 +28,12 @@ export async function navigateToUrl(browserSessionId, url) {
28
28
  }
29
29
  return currentBackend.navigateToUrl(browserSessionId, url);
30
30
  }
31
+ export async function closeCurrentTab(browserSessionId) {
32
+ if (!currentBackend?.closeCurrentTab) {
33
+ throw new Error("Active browser backend does not support closing browser tabs.");
34
+ }
35
+ return currentBackend.closeCurrentTab(browserSessionId);
36
+ }
31
37
  export { getCurrentPlatform };
32
38
  export async function disconnect() {
33
39
  if (currentBackend?.disconnect) {
@@ -10,6 +10,7 @@ export function getBrowserBackend(_platform = "browser") {
10
10
  getBrowserInfo: browserConnection.getBrowserInfo,
11
11
  getScreenshotAsBase64: browserConnection.getScreenshotAsBase64,
12
12
  navigateToUrl: browserConnection.navigateToUrl,
13
+ closeCurrentTab: browserConnection.closeCurrentTab,
13
14
  handleModelAction: browserActions.handleModelAction,
14
15
  disconnect: browserConnection.disconnect,
15
16
  };
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { emitDesktopDebug } from "../../utils/desktop-debug.js";
6
6
  import { requireInstalledBrowser } from "./installed-browser.js";
7
- import { clearActiveBrowserSession, getActiveBrowserSession, setActiveBrowserSession } from "./session-state.js";
7
+ import { clearActiveBrowserSession, getActiveBrowserSession, getSessionActiveBrowserPage, setActiveBrowserSession } from "./session-state.js";
8
8
  import { normalizeAutomationUrl, normalizeOptionalAutomationUrl } from "./url.js";
9
9
  import { getWelcomePageUrl, isWelcomePageUrl } from "./welcome-page.js";
10
10
  const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
@@ -77,6 +77,62 @@ function isCurrentSession(session) {
77
77
  return false;
78
78
  }
79
79
  }
80
+ function pageIsClosed(page) {
81
+ try {
82
+ return typeof page?.isClosed === "function" && page.isClosed();
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ function getActivePage(session) {
89
+ return getSessionActiveBrowserPage(session);
90
+ }
91
+ async function bringPageToFront(page) {
92
+ if (typeof page?.bringToFront !== "function") {
93
+ return;
94
+ }
95
+ try {
96
+ await page.bringToFront();
97
+ }
98
+ catch {
99
+ // Best-effort only. The logical active tab is still tracked in pageStack.
100
+ }
101
+ }
102
+ function pruneClosedPages(session) {
103
+ session.pageStack = session.pageStack.filter((page) => !pageIsClosed(page));
104
+ }
105
+ function trackPage(session, page) {
106
+ if (!page || session.pageStack.includes(page)) {
107
+ return;
108
+ }
109
+ page.on?.("close", () => {
110
+ handleTrackedPageClosed(session, page);
111
+ });
112
+ session.pageStack.push(page);
113
+ void bringPageToFront(page);
114
+ }
115
+ function handleTrackedPageClosed(session, page) {
116
+ if (session.disconnecting) {
117
+ return;
118
+ }
119
+ if (page === session._rootPage) {
120
+ handleExternalSessionClosed(session, "page_closed");
121
+ return;
122
+ }
123
+ const wasActive = getActivePage(session) === page;
124
+ session.pageStack = session.pageStack.filter((stackedPage) => stackedPage !== page);
125
+ pruneClosedPages(session);
126
+ if (wasActive) {
127
+ const restoredPage = getActivePage(session);
128
+ void bringPageToFront(restoredPage);
129
+ emitDesktopDebug("browser.popup", "device", { sessionId: session.sessionId }, {
130
+ stage: "closed",
131
+ restoredUrl: restoredPage?.url?.() || null,
132
+ stackDepth: session.pageStack.length,
133
+ });
134
+ }
135
+ }
80
136
  async function waitForPendingBrowserSessionShutdown() {
81
137
  if (!pendingBrowserSessionShutdown) {
82
138
  return;
@@ -267,21 +323,15 @@ export async function connectToBrowser(targetOrOptions) {
267
323
  const welcomePageUrl = await getWelcomePageUrl();
268
324
  const initialUrl = normalizedUrl || welcomePageUrl;
269
325
  await closeExtraPages(context, primaryPage);
270
- context.on("page", (page) => {
271
- if (page === primaryPage) {
272
- return;
273
- }
274
- console.warn("New tab or popup opened. Automation will remain attached to the original page only.");
275
- emitDesktopDebug("browser.popup", "device", { sessionId }, {
276
- stage: "opened",
277
- url: page.url() || null,
278
- });
279
- });
280
- await gotoWithTimeoutTolerance(primaryPage, initialUrl);
281
326
  const session = {
282
327
  sessionId,
283
328
  context,
284
- page: primaryPage,
329
+ _rootPage: primaryPage,
330
+ get page() {
331
+ return getActivePage(this);
332
+ },
333
+ pageStack: [primaryPage],
334
+ pageUrls: new WeakMap([[primaryPage, normalizedUrl]]),
285
335
  url: normalizedUrl,
286
336
  welcomePageUrl,
287
337
  sessionMode: options.sessionMode,
@@ -293,10 +343,22 @@ export async function connectToBrowser(targetOrOptions) {
293
343
  externalDisconnectNotified: false,
294
344
  shutdownPromise: null,
295
345
  };
296
- setActiveBrowserSession(session);
297
346
  primaryPage.on?.("close", () => {
298
- handleExternalSessionClosed(session, "page_closed");
347
+ handleTrackedPageClosed(session, primaryPage);
348
+ });
349
+ context.on("page", (page) => {
350
+ if (page === primaryPage) {
351
+ return;
352
+ }
353
+ trackPage(session, page);
354
+ emitDesktopDebug("browser.popup", "device", { sessionId }, {
355
+ stage: "opened",
356
+ url: page.url() || null,
357
+ stackDepth: session.pageStack.length,
358
+ });
299
359
  });
360
+ await gotoWithTimeoutTolerance(primaryPage, initialUrl);
361
+ setActiveBrowserSession(session);
300
362
  context.on?.("close", () => {
301
363
  handleExternalSessionClosed(session, "context_closed");
302
364
  });
@@ -313,18 +375,21 @@ export async function connectToBrowser(targetOrOptions) {
313
375
  }
314
376
  export async function getBrowserInfo(browserSessionId) {
315
377
  const session = getActiveBrowserSession(browserSessionId);
316
- const currentUrl = normalizeKnownPageUrl(session.page.url()) || session.url || null;
378
+ const activePage = getActivePage(session);
379
+ const currentUrl = normalizeKnownPageUrl(activePage.url()) || session.pageUrls.get(activePage) || null;
317
380
  return buildSessionBrowserInfo(currentUrl, session.installedBrowser);
318
381
  }
319
382
  export async function getScreenshotAsBase64(browserSessionId) {
320
383
  const session = getActiveBrowserSession(browserSessionId);
321
- const imageBuffer = await session.page.screenshot({ type: "png" });
384
+ const imageBuffer = await getActivePage(session).screenshot({ type: "png" });
322
385
  return imageBuffer.toString("base64");
323
386
  }
324
387
  export async function navigateToUrl(browserSessionId, url) {
325
388
  const session = getActiveBrowserSession(browserSessionId);
326
389
  const normalizedUrl = normalizeAutomationUrl(url);
327
- await gotoWithTimeoutTolerance(session.page, normalizedUrl);
390
+ const activePage = getActivePage(session);
391
+ await gotoWithTimeoutTolerance(activePage, normalizedUrl);
392
+ session.pageUrls.set(activePage, normalizedUrl);
328
393
  session.url = normalizedUrl;
329
394
  emitDesktopDebug("browser.navigate", "device", { browserSessionId }, {
330
395
  platform: "browser",
@@ -332,6 +397,40 @@ export async function navigateToUrl(browserSessionId, url) {
332
397
  });
333
398
  return normalizedUrl;
334
399
  }
400
+ export async function closeCurrentTab(browserSessionId) {
401
+ const session = getActiveBrowserSession(browserSessionId);
402
+ pruneClosedPages(session);
403
+ const activePage = getActivePage(session);
404
+ const previousPage = session.pageStack.length > 1
405
+ ? session.pageStack[session.pageStack.length - 2]
406
+ : null;
407
+ if (!activePage || activePage === session._rootPage || !previousPage) {
408
+ return {
409
+ closed: false,
410
+ restoredUrl: normalizeKnownPageUrl(activePage?.url?.()) || session.pageUrls.get(activePage) || null,
411
+ message: "No previous browser tab is available. The current tab was left open.",
412
+ };
413
+ }
414
+ await activePage.close();
415
+ if (session.pageStack.includes(activePage)) {
416
+ handleTrackedPageClosed(session, activePage);
417
+ }
418
+ const restoredPage = getActivePage(session);
419
+ const restoredUrl = normalizeKnownPageUrl(restoredPage?.url?.()) || session.pageUrls.get(restoredPage) || null;
420
+ emitDesktopDebug("browser.tab.close_current", "device", { browserSessionId }, {
421
+ platform: "browser",
422
+ closed: true,
423
+ restoredUrl,
424
+ stackDepth: session.pageStack.length,
425
+ });
426
+ return {
427
+ closed: true,
428
+ restoredUrl,
429
+ message: restoredUrl
430
+ ? `Closed current tab. Returned to ${restoredUrl}.`
431
+ : "Closed current tab. Returned to the previous browser tab.",
432
+ };
433
+ }
335
434
  export async function disconnect() {
336
435
  let session;
337
436
  try {
@@ -14,6 +14,13 @@ export function getActiveBrowserSession(browserSessionId = null) {
14
14
  }
15
15
  return activeSession;
16
16
  }
17
+ export function getSessionActiveBrowserPage(session) {
18
+ const stack = Array.isArray(session?.pageStack) ? session.pageStack : [];
19
+ if (stack.length > 0) {
20
+ return stack[stack.length - 1];
21
+ }
22
+ return session?._rootPage || session?.page;
23
+ }
17
24
  export function getActiveBrowserPage(browserSessionId = null) {
18
- return getActiveBrowserSession(browserSessionId).page;
25
+ return getSessionActiveBrowserPage(getActiveBrowserSession(browserSessionId));
19
26
  }
@@ -0,0 +1,64 @@
1
+ import path from "path";
2
+ import { readdir, stat } from "fs/promises";
3
+ import { resolveHeadlessInstructionsPath } from "./headless-instructions.js";
4
+ const testNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
5
+ const filenameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
6
+ function compareInstructionFiles(left, right) {
7
+ const byName = testNameCollator.compare(left.name, right.name);
8
+ if (byName !== 0) {
9
+ return byName;
10
+ }
11
+ const byModified = right.modified.localeCompare(left.modified);
12
+ if (byModified !== 0) {
13
+ return byModified;
14
+ }
15
+ return filenameCollator.compare(left.filename, right.filename);
16
+ }
17
+ /**
18
+ * Resolve a CLI instructions target as either a single .dcua file or a directory
19
+ * containing .dcua files.
20
+ *
21
+ * @param {string} instructionsPath
22
+ * @returns {Promise<{type: 'file'|'directory', path: string}>}
23
+ */
24
+ export async function resolveHeadlessInstructionsTarget(instructionsPath) {
25
+ const resolvedPath = await resolveHeadlessInstructionsPath(instructionsPath);
26
+ try {
27
+ const pathStat = await stat(resolvedPath);
28
+ if (pathStat.isDirectory()) {
29
+ return { type: "directory", path: resolvedPath };
30
+ }
31
+ }
32
+ catch {
33
+ // Missing paths stay as file targets so the existing read error remains clear.
34
+ }
35
+ return { type: "file", path: resolvedPath };
36
+ }
37
+ /**
38
+ * List runnable .dcua files from a headless instructions directory.
39
+ *
40
+ * @param {string} instructionsDirectory
41
+ * @returns {Promise<Array<{name: string, filename: string, path: string, modified: string}>>}
42
+ */
43
+ export async function listHeadlessInstructionFiles(instructionsDirectory) {
44
+ let entries;
45
+ try {
46
+ entries = await readdir(instructionsDirectory);
47
+ }
48
+ catch (error) {
49
+ const message = error instanceof Error ? error.message : "Unknown directory read error.";
50
+ throw new Error(`Could not read instructions directory ${instructionsDirectory}: ${message}`);
51
+ }
52
+ const dcuaFiles = entries.filter((entry) => entry.toLowerCase().endsWith(".dcua"));
53
+ const tests = await Promise.all(dcuaFiles.map(async (filename) => {
54
+ const fullPath = path.join(instructionsDirectory, filename);
55
+ const fileStat = await stat(fullPath);
56
+ return {
57
+ name: filename.replace(/\.dcua$/i, ""),
58
+ filename,
59
+ path: fullPath,
60
+ modified: fileStat.mtime.toISOString()
61
+ };
62
+ }));
63
+ return tests.sort(compareInstructionFiles);
64
+ }