@samsara-dev/appwright 0.9.15 → 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,11 @@
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
+
3
9
  ## 0.9.15
4
10
 
5
11
  ### Patch Changes
@@ -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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samsara-dev/appwright",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/",
6
6
  "access": "public"