@samsara-dev/appwright 0.9.13 → 0.9.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # appwright
2
2
 
3
+ ## 0.9.16
4
+
5
+ ### Patch Changes
6
+
7
+ - 7e73c82: Fix persistent device video download race condition that caused the process to hang when a worker had only one test (common with retries). When the 5-second delay wasn't enough for endTime to be written, the code misclassified the last test as intermediate and waited for a file that only the endTime download path would create. The intermediate test branch now polls for both the video file on disk and endTime being set, and all downloads go through a per-session lock to prevent concurrent writes.
8
+
9
+ ## 0.9.15
10
+
11
+ ### Patch Changes
12
+
13
+ - 7e70815: Fix stale testId in GPT Driver when using persistent device fixtures
14
+
15
+ Update testId on the cached gpt-driver-node instance before every API call
16
+ so persistent device fixtures (scope: "worker") report the correct test in
17
+ GPT Driver sessions. Previously the testId was baked in on first use and
18
+ never updated, causing all subsequent tests and resetToHome teardowns to
19
+ report the same stale testId on the MobileBoost dashboard.
20
+
21
+ ## 0.9.14
22
+
23
+ ### Patch Changes
24
+
25
+ - c7b7722: Switch GPT Driver cachingMode from FULL_SCREEN to INTERACTION_REGION
26
+
3
27
  ## 0.9.13
4
28
 
5
29
  ### Patch Changes
@@ -2,7 +2,7 @@ import type { Client as WebDriverClient } from "webdriver";
2
2
  import type { GptDriverConfig } from "../types";
3
3
  /**
4
4
  * Options for aiExecute. Intentionally exposes only appiumHandler;
5
- * cachingMode is set globally at provider init (FULL_SCREEN),
5
+ * cachingMode is set globally at provider init (INTERACTION_REGION),
6
6
  * and useSmartLoop is not yet surfaced.
7
7
  */
8
8
  export interface AiExecuteOptions {
@@ -32,6 +32,7 @@ export declare class GptDriverProvider implements GptDriverApi {
32
32
  private webDriverClient;
33
33
  private options?;
34
34
  private driver;
35
+ private testIdWarned;
35
36
  constructor(webDriverClient: WebDriverClient, options?: GptDriverConfig | undefined);
36
37
  private getAppiumUrl;
37
38
  private getDriver;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/gptDriver/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,IAAI,eAAe,EAAE,MAAM,WAAW,CAAC;AAG3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1E,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAClE,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;CAC9D;AAED;;;;;;;;;GASG;AACH,qBAAa,iBAAkB,YAAW,YAAY;IAIlD,OAAO,CAAC,eAAe;IACvB,OAAO,CAAC,OAAO,CAAC;IAJlB,OAAO,CAAC,MAAM,CAA0B;gBAG9B,eAAe,EAAE,eAAe,EAChC,OAAO,CAAC,EAAE,eAAe,YAAA;IAGnC,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,SAAS;IA0BjB,OAAO,CAAC,aAAa;IAYrB,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,kBAAkB;IAS1B,OAAO,CAAC,aAAa;IAMf,SAAS,CACb,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,IAAI,CAAC;IAkBV,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAaxC,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAa/C,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAajE,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAWnE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/gptDriver/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,IAAI,eAAe,EAAE,MAAM,WAAW,CAAC;AAG3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1E,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAClE,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;CAC9D;AAED;;;;;;;;;GASG;AACH,qBAAa,iBAAkB,YAAW,YAAY;IAKlD,OAAO,CAAC,eAAe;IACvB,OAAO,CAAC,OAAO,CAAC;IALlB,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,YAAY,CAAS;gBAGnB,eAAe,EAAE,eAAe,EAChC,OAAO,CAAC,EAAE,eAAe,YAAA;IAGnC,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,SAAS;IA0BjB,OAAO,CAAC,aAAa;IA8BrB,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,kBAAkB;IAS1B,OAAO,CAAC,aAAa;IAMf,SAAS,CACb,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC,IAAI,CAAC;IAkBV,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAaxC,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAa/C,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAajE,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAWnE"}
@@ -77,6 +77,7 @@ let GptDriverProvider = (() => {
77
77
  webDriverClient = __runInitializers(this, _instanceExtraInitializers);
78
78
  options;
79
79
  driver = null;
80
+ testIdWarned = false;
80
81
  constructor(webDriverClient, options) {
81
82
  this.webDriverClient = webDriverClient;
82
83
  this.options = options;
@@ -100,7 +101,7 @@ let GptDriverProvider = (() => {
100
101
  // gpt-driver-node expects webdriverio Browser type but webdriver Client is API-compatible at runtime
101
102
  driver: this.webDriverClient,
102
103
  serverConfig: { url: this.getAppiumUrl() },
103
- cachingMode: "FULL_SCREEN",
104
+ cachingMode: "INTERACTION_REGION",
104
105
  testId: test_1.default.info()?.testId ?? `test-${Date.now()}`,
105
106
  ...(this.options?.additionalUserContext != null && {
106
107
  additionalUserContext: this.options.additionalUserContext,
@@ -115,6 +116,21 @@ let GptDriverProvider = (() => {
115
116
  test_1.default.skip(true, "GPT Driver not configured. Set GPT_DRIVER_API_KEY environment variable.");
116
117
  throw new Error("GPT Driver not configured");
117
118
  }
119
+ // Update testId to current test context so persistent device fixtures
120
+ // report the correct test in GPT Driver API calls.
121
+ // gpt-driver-node declares testId as private in TS but it's a plain
122
+ // JS property at runtime — safe to mutate directly.
123
+ // TODO: Replace with public setTestId() if gpt-driver-node exposes one.
124
+ const currentTestId = test_1.default.info()?.testId;
125
+ if (currentTestId && "testId" in driver) {
126
+ driver.testId = currentTestId;
127
+ }
128
+ else if (currentTestId && !this.testIdWarned) {
129
+ console.warn("[GptDriver] Cannot update testId — property not found on driver instance. " +
130
+ "GPT Driver sessions may be attributed to the wrong test. " +
131
+ "Check if gpt-driver-node renamed or removed the testId field.");
132
+ this.testIdWarned = true;
133
+ }
118
134
  return driver;
119
135
  }
120
136
  validateInstruction(instruction, methodName) {
@@ -1,11 +1,21 @@
1
1
  import type { Reporter, TestCase, TestResult } from "@playwright/test/reporter";
2
2
  declare class VideoDownloader implements Reporter {
3
3
  private downloadPromises;
4
+ private sessionDownloadLocks;
4
5
  onBegin(): void;
5
6
  onTestBegin(test: TestCase, result: TestResult): void;
6
7
  onTestEnd(test: TestCase, result: TestResult): void;
7
8
  onEnd(): Promise<void>;
8
9
  private trimAndAttachPersistentDeviceVideo;
10
+ /**
11
+ * Ensures exactly one download per session. The first caller triggers the
12
+ * download; concurrent callers await the same promise. Returns the
13
+ * downloaded file path, or null if the download failed.
14
+ *
15
+ * Keyed by sessionId (not workerIndex) because Playwright can reuse
16
+ * workerIndex across worker restarts with different BrowserStack sessions.
17
+ */
18
+ private ensureWorkerVideoDownloaded;
9
19
  private downloadAndAttachDeviceVideo;
10
20
  private providerSupportsVideo;
11
21
  }
@@ -1 +1 @@
1
- {"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../src/reporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAUhF,cAAM,eAAgB,YAAW,QAAQ;IACvC,OAAO,CAAC,gBAAgB,CAAsB;IAE9C,OAAO;IAQP,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;IAU9C,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;IAuGtC,KAAK;YAKG,kCAAkC;IA6ChD,OAAO,CAAC,4BAA4B;IAyBpC,OAAO,CAAC,qBAAqB;CAU9B;AAwHD,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../src/reporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAUhF,cAAM,eAAgB,YAAW,QAAQ;IACvC,OAAO,CAAC,gBAAgB,CAAsB;IAK9C,OAAO,CAAC,oBAAoB,CAA6C;IAEzE,OAAO;IAQP,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;IAU9C,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;IAgItC,KAAK;YAKG,kCAAkC;IA6ChD;;;;;;;OAOG;YACW,2BAA2B;IAqBzC,OAAO,CAAC,4BAA4B;IAyBpC,OAAO,CAAC,qBAAqB;CAU9B;AA0ID,eAAe,eAAe,CAAC"}
package/dist/reporter.js CHANGED
@@ -13,6 +13,11 @@ const utils_1 = require("./utils");
13
13
  const workerInfo_1 = require("./fixture/workerInfo");
14
14
  class VideoDownloader {
15
15
  downloadPromises = [];
16
+ // Ensures only one download per session — prevents concurrent downloads
17
+ // when multiple tests on the same worker resolve endTime simultaneously.
18
+ // Keyed by sessionId (not workerIndex) because Playwright can reuse
19
+ // workerIndex across worker restarts with different sessions.
20
+ sessionDownloadLocks = new Map();
16
21
  onBegin() {
17
22
  if (fs_1.default.existsSync((0, utils_1.basePath)())) {
18
23
  fs_1.default.rmSync((0, utils_1.basePath)(), {
@@ -53,7 +58,7 @@ class VideoDownloader {
53
58
  type: "workerInfo",
54
59
  description: `Ran on worker #${workerIndex}.`,
55
60
  });
56
- // The `onTestEnd` is method is called before the worker ends and
61
+ // The `onTestEnd` method is called before the worker ends and
57
62
  // the worker's `endTime` is saved to disk. We add a 5 secs delay
58
63
  // to prevent a harmful race condition.
59
64
  const workerDownload = getWorkerInfo(workerIndex)
@@ -77,21 +82,37 @@ class VideoDownloader {
77
82
  return; // Nothing to do here
78
83
  }
79
84
  const workerVideoBaseName = `worker-${workerIndex}-${sessionId}-video`;
85
+ const expectedWorkerVideoPath = path_1.default.join((0, utils_1.basePath)(), `${workerVideoBaseName}.mp4`);
80
86
  if (endTime) {
81
- // This is the last test in the worker, so let's download the video
82
- const provider = (0, providers_1.getProviderClass)(providerName);
83
- const downloaded = await provider.downloadVideo(sessionId, (0, utils_1.basePath)(), workerVideoBaseName);
84
- if (!downloaded) {
87
+ // Last test in the worker download through the lock
88
+ // so concurrent endTime resolutions don't race.
89
+ const downloadedPath = await this.ensureWorkerVideoDownloaded(sessionId, providerName, workerVideoBaseName);
90
+ if (!downloadedPath) {
85
91
  return;
86
92
  }
87
- return this.trimAndAttachPersistentDeviceVideo(test, result, downloaded.path);
93
+ return this.trimAndAttachPersistentDeviceVideo(test, result, downloadedPath);
88
94
  }
89
95
  else {
90
- // This is an intermediate test in the worker, so let's wait for the
91
- // video file to be found on disk. Once it is, we trim and attach it.
92
- const expectedWorkerVideoPath = path_1.default.join((0, utils_1.basePath)(), `${workerVideoBaseName}.mp4`);
93
- await waitFor(() => fs_1.default.existsSync(expectedWorkerVideoPath));
94
- return this.trimAndAttachPersistentDeviceVideo(test, result, expectedWorkerVideoPath);
96
+ // This looks like an intermediate test, but endTime may not
97
+ // have been written yet (race condition common when a worker
98
+ // has only one test, e.g. retries). Poll for EITHER the video
99
+ // file on disk (written by the last test's download) OR
100
+ // endTime appearing (meaning we need to download ourselves).
101
+ const resolved = await waitForFileOrEndTime(expectedWorkerVideoPath, workerIndex);
102
+ if (resolved === "file") {
103
+ return this.trimAndAttachPersistentDeviceVideo(test, result, expectedWorkerVideoPath);
104
+ }
105
+ else {
106
+ // endTime was set but file doesn't exist yet. Use the
107
+ // per-session lock so only one test triggers the download;
108
+ // others await the same promise.
109
+ const downloadedPath = await this.ensureWorkerVideoDownloaded(sessionId, providerName, workerVideoBaseName);
110
+ if (!downloadedPath) {
111
+ logger_1.logger.error(`Video download returned null for session ${sessionId}, skipping attachment`);
112
+ return;
113
+ }
114
+ return this.trimAndAttachPersistentDeviceVideo(test, result, downloadedPath);
115
+ }
95
116
  }
96
117
  });
97
118
  })
@@ -146,6 +167,25 @@ class VideoDownloader {
146
167
  name: "video",
147
168
  });
148
169
  }
170
+ /**
171
+ * Ensures exactly one download per session. The first caller triggers the
172
+ * download; concurrent callers await the same promise. Returns the
173
+ * downloaded file path, or null if the download failed.
174
+ *
175
+ * Keyed by sessionId (not workerIndex) because Playwright can reuse
176
+ * workerIndex across worker restarts with different BrowserStack sessions.
177
+ */
178
+ async ensureWorkerVideoDownloaded(sessionId, providerName, workerVideoBaseName) {
179
+ if (!this.sessionDownloadLocks.has(sessionId)) {
180
+ const downloadPromise = (async () => {
181
+ const provider = (0, providers_1.getProviderClass)(providerName);
182
+ const downloaded = await provider.downloadVideo(sessionId, (0, utils_1.basePath)(), workerVideoBaseName);
183
+ return downloaded?.path ?? null;
184
+ })();
185
+ this.sessionDownloadLocks.set(sessionId, downloadPromise);
186
+ }
187
+ return this.sessionDownloadLocks.get(sessionId);
188
+ }
149
189
  downloadAndAttachDeviceVideo(test, result, providerClass, sessionId) {
150
190
  const videoFileName = `${sessionId}-${test.id}`;
151
191
  if (!providerClass.downloadVideo) {
@@ -174,21 +214,37 @@ class VideoDownloader {
174
214
  return !!provider.downloadVideo;
175
215
  }
176
216
  }
177
- function waitFor(condition, timeout = 60 * 60 * 1000) {
178
- return new Promise((resolve, reject) => {
179
- let interval;
180
- const timeoutId = setTimeout(() => {
181
- clearInterval(interval);
182
- reject(new Error("Timed out waiting for condition"));
183
- }, timeout);
184
- interval = setInterval(() => {
185
- if (condition()) {
186
- clearInterval(interval);
187
- clearTimeout(timeoutId);
188
- resolve();
217
+ /**
218
+ * Poll until the video file appears on disk OR the worker's endTime is set.
219
+ *
220
+ * When a worker has only one test (common with retries), the 5-second delay
221
+ * before checking endTime may not be enough — the worker teardown can take
222
+ * longer than 5 seconds. In that case onTestEnd
223
+ * incorrectly treats the last test as "intermediate" and waits for a video file
224
+ * that will never be created. This function resolves the race by checking both
225
+ * conditions in parallel.
226
+ *
227
+ * Uses validateMp4 instead of fs.existsSync to ensure the file is fully
228
+ * written (has a valid moov atom) before returning.
229
+ */
230
+ async function waitForFileOrEndTime(filePath, workerIndex, timeout = 5 * 60 * 1000) {
231
+ const deadline = Date.now() + timeout;
232
+ while (Date.now() < deadline) {
233
+ if (fs_1.default.existsSync(filePath) && validateMp4(filePath)) {
234
+ return "file";
235
+ }
236
+ try {
237
+ const info = await getWorkerInfo(workerIndex);
238
+ if (info?.endTime) {
239
+ return "endTime";
189
240
  }
190
- }, 500);
191
- });
241
+ }
242
+ catch {
243
+ // getWorkerInfo can fail transiently; keep polling
244
+ }
245
+ await new Promise((r) => setTimeout(r, 500));
246
+ }
247
+ throw new Error(`Timed out waiting for video file or worker ${workerIndex} endTime`);
192
248
  }
193
249
  /**
194
250
  * Validate that an MP4 file has a valid moov atom (metadata header).
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=gptDriver.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gptDriver.spec.d.ts","sourceRoot":"","sources":["../../src/tests/gptDriver.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const vitest_1 = require("vitest");
7
+ const test_1 = __importDefault(require("@playwright/test"));
8
+ // --- Mock gpt-driver-node ---
9
+ // vi.hoisted runs before vi.mock hoisting, so variables are available in the factory
10
+ const { MockGptDriver, mockAiExecute } = vitest_1.vi.hoisted(() => {
11
+ const mockAiExecute = vitest_1.vi.fn().mockResolvedValue(undefined);
12
+ // Must use function (not arrow) so it can be called with `new`
13
+ const MockGptDriver = vitest_1.vi.fn(function (config) {
14
+ this.aiExecute = mockAiExecute;
15
+ this.assert = vitest_1.vi.fn().mockResolvedValue(undefined);
16
+ this.assertBulk = vitest_1.vi.fn().mockResolvedValue(undefined);
17
+ this.checkBulk = vitest_1.vi.fn().mockResolvedValue({});
18
+ this.extract = vitest_1.vi.fn().mockResolvedValue({});
19
+ this.testId = config.testId;
20
+ });
21
+ return { MockGptDriver, mockAiExecute };
22
+ });
23
+ vitest_1.vi.mock("gpt-driver-node", () => ({ default: MockGptDriver }));
24
+ // --- Mock Playwright test helpers ---
25
+ let currentTestId = "test-initial";
26
+ test_1.default.step = vitest_1.vi.fn(async (_name, body) => await body());
27
+ test_1.default.info = () => currentTestId ? { testId: currentTestId } : undefined;
28
+ test_1.default.skip = vitest_1.vi.fn();
29
+ const gptDriver_1 = require("../gptDriver");
30
+ function createProvider() {
31
+ return new gptDriver_1.GptDriverProvider({
32
+ options: {
33
+ protocol: "http",
34
+ hostname: "localhost",
35
+ port: 4723,
36
+ path: "/wd/hub",
37
+ },
38
+ });
39
+ }
40
+ (0, vitest_1.describe)("GptDriverProvider", () => {
41
+ (0, vitest_1.beforeEach)(() => {
42
+ vitest_1.vi.clearAllMocks();
43
+ currentTestId = "test-initial";
44
+ process.env.GPT_DRIVER_API_KEY = "fake-key";
45
+ });
46
+ (0, vitest_1.afterEach)(() => {
47
+ delete process.env.GPT_DRIVER_API_KEY;
48
+ });
49
+ (0, vitest_1.describe)("testId tracks current test context", () => {
50
+ (0, vitest_1.test)("initializes testId from first call's test context", async () => {
51
+ const provider = createProvider();
52
+ currentTestId = "test-first";
53
+ await provider.aiExecute("login");
54
+ const driver = provider.driver;
55
+ (0, vitest_1.expect)(driver.testId).toBe("test-first");
56
+ });
57
+ (0, vitest_1.test)("updates testId when a different test uses the same device", async () => {
58
+ const provider = createProvider();
59
+ // Test A runs
60
+ currentTestId = "test-aaa";
61
+ await provider.aiExecute("tap the submit button");
62
+ (0, vitest_1.expect)(provider.driver.testId).toBe("test-aaa");
63
+ // Test B runs on the same persistent device
64
+ currentTestId = "test-bbb";
65
+ await provider.aiExecute("verify the results screen");
66
+ (0, vitest_1.expect)(provider.driver.testId).toBe("test-bbb");
67
+ });
68
+ (0, vitest_1.test)("fixture teardown uses the current test's ID, not the first", async () => {
69
+ const provider = createProvider();
70
+ // Test A runs
71
+ currentTestId = "test-aaa";
72
+ await provider.aiExecute("perform action");
73
+ // Fixture teardown runs in Test A's context
74
+ await provider.aiExecute("navigate back to home");
75
+ (0, vitest_1.expect)(provider.driver.testId).toBe("test-aaa");
76
+ // Test B starts with new context
77
+ currentTestId = "test-bbb";
78
+ await provider.aiExecute("perform another action");
79
+ (0, vitest_1.expect)(provider.driver.testId).toBe("test-bbb");
80
+ // Fixture teardown runs in Test B's context
81
+ await provider.aiExecute("navigate back to home");
82
+ (0, vitest_1.expect)(provider.driver.testId).toBe("test-bbb");
83
+ });
84
+ (0, vitest_1.test)("preserves testId when test.info() returns undefined", async () => {
85
+ const provider = createProvider();
86
+ currentTestId = "test-known";
87
+ await provider.aiExecute("do something");
88
+ (0, vitest_1.expect)(provider.driver.testId).toBe("test-known");
89
+ // Worker teardown — no test context
90
+ currentTestId = undefined;
91
+ await provider.aiExecute("cleanup");
92
+ (0, vitest_1.expect)(provider.driver.testId).toBe("test-known");
93
+ });
94
+ (0, vitest_1.test)("warns once if testId property is missing from driver", async () => {
95
+ const provider = createProvider();
96
+ const warnSpy = vitest_1.vi.spyOn(console, "warn").mockImplementation(() => { });
97
+ currentTestId = "test-aaa";
98
+ await provider.aiExecute("initialize");
99
+ // Remove testId to simulate SDK rename/removal
100
+ delete provider.driver.testId;
101
+ currentTestId = "test-bbb";
102
+ await provider.aiExecute("second call");
103
+ (0, vitest_1.expect)(warnSpy).toHaveBeenCalledTimes(1);
104
+ (0, vitest_1.expect)(warnSpy).toHaveBeenCalledWith(vitest_1.expect.stringContaining("Cannot update testId"));
105
+ // Should not warn again on subsequent calls
106
+ currentTestId = "test-ccc";
107
+ await provider.aiExecute("third call");
108
+ (0, vitest_1.expect)(warnSpy).toHaveBeenCalledTimes(1);
109
+ warnSpy.mockRestore();
110
+ });
111
+ });
112
+ (0, vitest_1.describe)("lazy singleton", () => {
113
+ (0, vitest_1.test)("creates GptDriver instance only once", async () => {
114
+ const provider = createProvider();
115
+ currentTestId = "test-1";
116
+ await provider.aiExecute("first");
117
+ currentTestId = "test-2";
118
+ await provider.aiExecute("second");
119
+ currentTestId = "test-3";
120
+ await provider.aiExecute("third");
121
+ (0, vitest_1.expect)(MockGptDriver).toHaveBeenCalledTimes(1);
122
+ });
123
+ (0, vitest_1.test)("does not create GptDriver when API key is missing", async () => {
124
+ delete process.env.GPT_DRIVER_API_KEY;
125
+ const provider = createProvider();
126
+ // Should call test.skip and throw
127
+ await (0, vitest_1.expect)(provider.aiExecute("anything")).rejects.toThrow("GPT Driver not configured");
128
+ (0, vitest_1.expect)(MockGptDriver).not.toHaveBeenCalled();
129
+ });
130
+ });
131
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samsara-dev/appwright",
3
- "version": "0.9.13",
3
+ "version": "0.9.16",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/",
6
6
  "access": "public"