@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 +6 -0
- package/dist/reporter.d.ts +10 -0
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +81 -25
- package/package.json +1 -1
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
|
package/dist/reporter.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/reporter.d.ts.map
CHANGED
|
@@ -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;
|
|
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`
|
|
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
|
-
//
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
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,
|
|
93
|
+
return this.trimAndAttachPersistentDeviceVideo(test, result, downloadedPath);
|
|
88
94
|
}
|
|
89
95
|
else {
|
|
90
|
-
// This
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
}
|
|
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).
|