@samsara-dev/appwright 0.7.0 → 0.7.2
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 +28 -0
- package/dist/providers/browserstack/index.d.ts.map +1 -1
- package/dist/providers/browserstack/index.js +25 -5
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +8 -6
- package/dist/tests/reporter.spec.d.ts +2 -0
- package/dist/tests/reporter.spec.d.ts.map +1 -0
- package/dist/tests/reporter.spec.js +167 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# appwright
|
|
2
2
|
|
|
3
|
+
## 0.7.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ecdfc42: Avoid video filename collisions in the Playwright reporter by including the provider session ID in downloaded video filenames.
|
|
8
|
+
|
|
9
|
+
## 0.7.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 75adf30: Add CI metadata support for BrowserStack build traceability
|
|
14
|
+
|
|
15
|
+
BrowserStack sessions now automatically include CI context in build names and session names:
|
|
16
|
+
|
|
17
|
+
- **Buildkite**: Build number, branch, commit from `BUILDKITE_*` env vars
|
|
18
|
+
- **GitHub Actions**: Run number, ref name, SHA from `GITHUB_*` env vars
|
|
19
|
+
- **GitLab CI**: Pipeline ID, ref name, commit from `CI_*` env vars
|
|
20
|
+
|
|
21
|
+
Example build names:
|
|
22
|
+
|
|
23
|
+
- CI: `driver-performance-tests android #35 (main)`
|
|
24
|
+
- Local: `driver-performance-tests android`
|
|
25
|
+
|
|
26
|
+
Environment variable overrides:
|
|
27
|
+
|
|
28
|
+
- `BROWSERSTACK_BUILD_NAME`: Override auto-generated build name
|
|
29
|
+
- `BROWSERSTACK_SESSION_NAME`: Override auto-generated session name
|
|
30
|
+
|
|
3
31
|
## 0.7.0
|
|
4
32
|
|
|
5
33
|
### Minor Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/providers/browserstack/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,eAAe,EACf,cAAc,EAGf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAsDtC,qBAAa,0BAA2B,YAAW,cAAc;IAC/D,OAAO,CAAC,cAAc,CAAC,CAA6B;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,OAAO,CAA+B;gBAG5C,OAAO,EAAE,WAAW,CAAC,eAAe,CAAC,EACrC,WAAW,EAAE,MAAM,GAAG,SAAS;IAU3B,WAAW;IA0EX,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC;IAMlC,OAAO,CAAC,cAAc;YASR,YAAY;YAiBZ,iBAAiB;YAKjB,yBAAyB;WAK1B,aAAa,CACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAuFlD,eAAe,CAAC,OAAO,EAAE;QAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,IAAI,CAAC,EAAE,MAAM,CAAC;KACf;IA2BD,OAAO,CAAC,YAAY;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/providers/browserstack/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,eAAe,EACf,cAAc,EAGf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAsDtC,qBAAa,0BAA2B,YAAW,cAAc;IAC/D,OAAO,CAAC,cAAc,CAAC,CAA6B;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,OAAO,CAA+B;gBAG5C,OAAO,EAAE,WAAW,CAAC,eAAe,CAAC,EACrC,WAAW,EAAE,MAAM,GAAG,SAAS;IAU3B,WAAW;IA0EX,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC;IAMlC,OAAO,CAAC,cAAc;YASR,YAAY;YAiBZ,iBAAiB;YAKjB,yBAAyB;WAK1B,aAAa,CACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAuFlD,eAAe,CAAC,OAAO,EAAE;QAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,IAAI,CAAC,EAAE,MAAM,CAAC;KACf;IA2BD,OAAO,CAAC,YAAY;CAsJrB"}
|
|
@@ -251,6 +251,28 @@ class BrowserStackDeviceProvider {
|
|
|
251
251
|
throw new Error(`process.env.${envVarKey} is not set. Did the file upload work?`);
|
|
252
252
|
}
|
|
253
253
|
const permissionPrompts = deviceConfig?.permissionPrompts;
|
|
254
|
+
// Build CI-aware metadata for better traceability between CI builds and BrowserStack sessions
|
|
255
|
+
const ciBuildIdentifier = process.env.BUILDKITE_BUILD_ID ||
|
|
256
|
+
process.env.GITHUB_RUN_ID ||
|
|
257
|
+
process.env.CI_JOB_ID || // GitLab CI
|
|
258
|
+
process.env.USER;
|
|
259
|
+
const ciBuildNumber = process.env.BUILDKITE_BUILD_NUMBER ||
|
|
260
|
+
process.env.GITHUB_RUN_NUMBER ||
|
|
261
|
+
process.env.CI_PIPELINE_IID; // GitLab CI
|
|
262
|
+
const ciBranch = process.env.BUILDKITE_BRANCH ||
|
|
263
|
+
process.env.GITHUB_REF_NAME ||
|
|
264
|
+
process.env.CI_COMMIT_REF_NAME; // GitLab CI
|
|
265
|
+
const ciCommit = (process.env.BUILDKITE_COMMIT ||
|
|
266
|
+
process.env.GITHUB_SHA ||
|
|
267
|
+
process.env.CI_COMMIT_SHA) // GitLab CI
|
|
268
|
+
?.substring(0, 7);
|
|
269
|
+
// Allow env var override, otherwise build a descriptive name with CI context
|
|
270
|
+
const defaultBuildName = ciBuildNumber
|
|
271
|
+
? `${projectName} ${platformName} #${ciBuildNumber}${ciBranch ? ` (${ciBranch})` : ""}`
|
|
272
|
+
: `${projectName} ${platformName}`;
|
|
273
|
+
const defaultSessionName = ciCommit
|
|
274
|
+
? `${projectName} ${platformName} test @ ${ciCommit}`
|
|
275
|
+
: `${projectName} ${platformName} test`;
|
|
254
276
|
const bstackOptions = {
|
|
255
277
|
debug: true,
|
|
256
278
|
interactiveDebugging: true,
|
|
@@ -262,11 +284,9 @@ class BrowserStackDeviceProvider {
|
|
|
262
284
|
osVersion: deviceConfig.osVersion,
|
|
263
285
|
platformName: platformName,
|
|
264
286
|
deviceOrientation: deviceConfig?.orientation,
|
|
265
|
-
buildName:
|
|
266
|
-
sessionName:
|
|
267
|
-
buildIdentifier:
|
|
268
|
-
? `CI ${process.env.GITHUB_RUN_ID}`
|
|
269
|
-
: process.env.USER,
|
|
287
|
+
buildName: process.env.BROWSERSTACK_BUILD_NAME || defaultBuildName,
|
|
288
|
+
sessionName: process.env.BROWSERSTACK_SESSION_NAME || defaultSessionName,
|
|
289
|
+
buildIdentifier: ciBuildIdentifier,
|
|
270
290
|
};
|
|
271
291
|
if (typeof deviceConfig?.appProfiling === "boolean") {
|
|
272
292
|
bstackOptions.appProfiling = deviceConfig.appProfiling;
|
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;IAE9C,OAAO;IAQP,WAAW,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;IAU9C,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU;
|
|
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;IA2FtC,KAAK;YAKG,kCAAkC;IA6ChD,OAAO,CAAC,4BAA4B;IAyBpC,OAAO,CAAC,qBAAqB;CAI9B;AA8ED,eAAe,eAAe,CAAC"}
|
package/dist/reporter.js
CHANGED
|
@@ -51,7 +51,6 @@ class VideoDownloader {
|
|
|
51
51
|
type: "workerInfo",
|
|
52
52
|
description: `Ran on worker #${workerIndex}.`,
|
|
53
53
|
});
|
|
54
|
-
const expectedVideoPath = path_1.default.join((0, utils_1.basePath)(), `worker-${workerIndex}-video.mp4`);
|
|
55
54
|
// The `onTestEnd` is method is called before the worker ends and
|
|
56
55
|
// the worker's `endTime` is saved to disk. We add a 5 secs delay
|
|
57
56
|
// to prevent a harmful race condition.
|
|
@@ -68,10 +67,11 @@ class VideoDownloader {
|
|
|
68
67
|
if (!this.providerSupportsVideo(providerName)) {
|
|
69
68
|
return; // Nothing to do here
|
|
70
69
|
}
|
|
70
|
+
const workerVideoBaseName = `worker-${workerIndex}-${sessionId}-video`;
|
|
71
71
|
if (endTime) {
|
|
72
72
|
// This is the last test in the worker, so let's download the video
|
|
73
73
|
const provider = (0, providers_1.getProviderClass)(providerName);
|
|
74
|
-
const downloaded = await provider.downloadVideo(sessionId, (0, utils_1.basePath)(),
|
|
74
|
+
const downloaded = await provider.downloadVideo(sessionId, (0, utils_1.basePath)(), workerVideoBaseName);
|
|
75
75
|
if (!downloaded) {
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
@@ -80,8 +80,9 @@ class VideoDownloader {
|
|
|
80
80
|
else {
|
|
81
81
|
// This is an intermediate test in the worker, so let's wait for the
|
|
82
82
|
// video file to be found on disk. Once it is, we trim and attach it.
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const expectedWorkerVideoPath = path_1.default.join((0, utils_1.basePath)(), `${workerVideoBaseName}.mp4`);
|
|
84
|
+
await waitFor(() => fs_1.default.existsSync(expectedWorkerVideoPath));
|
|
85
|
+
return this.trimAndAttachPersistentDeviceVideo(test, result, expectedWorkerVideoPath);
|
|
85
86
|
}
|
|
86
87
|
})
|
|
87
88
|
.catch((e) => {
|
|
@@ -111,7 +112,8 @@ class VideoDownloader {
|
|
|
111
112
|
}
|
|
112
113
|
else {
|
|
113
114
|
const trimSkipPoint = (testStart.getTime() - workerStart.getTime()) / 1000;
|
|
114
|
-
const
|
|
115
|
+
const retryIndex = result.retry ?? 0;
|
|
116
|
+
const trimmedFileName = `worker-${workerIdx}-trimmed-${test.id}-retry-${retryIndex}.mp4`;
|
|
115
117
|
try {
|
|
116
118
|
pathToAttach = await trimVideo({
|
|
117
119
|
originalVideoPath: workerVideoPath,
|
|
@@ -135,7 +137,7 @@ class VideoDownloader {
|
|
|
135
137
|
});
|
|
136
138
|
}
|
|
137
139
|
downloadAndAttachDeviceVideo(test, result, providerClass, sessionId) {
|
|
138
|
-
const videoFileName = `${test.id}`;
|
|
140
|
+
const videoFileName = `${sessionId}-${test.id}`;
|
|
139
141
|
if (!providerClass.downloadVideo) {
|
|
140
142
|
return;
|
|
141
143
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reporter.spec.d.ts","sourceRoot":"","sources":["../../src/tests/reporter.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,167 @@
|
|
|
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 promises_1 = __importDefault(require("fs/promises"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
vitest_1.vi.mock("@ffmpeg-installer/ffmpeg", () => {
|
|
11
|
+
return {
|
|
12
|
+
default: { path: "/fake/ffmpeg" },
|
|
13
|
+
__esModule: true,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
vitest_1.vi.mock("fluent-ffmpeg", () => {
|
|
17
|
+
return {
|
|
18
|
+
default: () => {
|
|
19
|
+
const handlers = {};
|
|
20
|
+
const chain = {
|
|
21
|
+
setFfmpegPath: () => chain,
|
|
22
|
+
setStartTime: () => chain,
|
|
23
|
+
setDuration: () => chain,
|
|
24
|
+
output: () => chain,
|
|
25
|
+
on: (event, cb) => {
|
|
26
|
+
handlers[event] = cb;
|
|
27
|
+
return chain;
|
|
28
|
+
},
|
|
29
|
+
run: () => {
|
|
30
|
+
void Promise.resolve().then(() => handlers.end?.());
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return chain;
|
|
34
|
+
},
|
|
35
|
+
__esModule: true,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
let mockBasePath = "";
|
|
39
|
+
const downloadVideoMock = vitest_1.vi.fn();
|
|
40
|
+
const getProviderClassMock = vitest_1.vi.fn(() => ({
|
|
41
|
+
downloadVideo: downloadVideoMock,
|
|
42
|
+
}));
|
|
43
|
+
vitest_1.vi.mock("../providers", () => {
|
|
44
|
+
return {
|
|
45
|
+
getProviderClass: getProviderClassMock,
|
|
46
|
+
__esModule: true,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
vitest_1.vi.mock("../utils", () => {
|
|
50
|
+
return {
|
|
51
|
+
basePath: () => mockBasePath,
|
|
52
|
+
__esModule: true,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
vitest_1.vi.mock("../logger", () => {
|
|
56
|
+
return {
|
|
57
|
+
logger: {
|
|
58
|
+
log: vitest_1.vi.fn(),
|
|
59
|
+
warn: vitest_1.vi.fn(),
|
|
60
|
+
error: vitest_1.vi.fn(),
|
|
61
|
+
},
|
|
62
|
+
__esModule: true,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
let VideoDownloader;
|
|
66
|
+
(0, vitest_1.beforeAll)(async () => {
|
|
67
|
+
const reporterModule = await import("../reporter.js");
|
|
68
|
+
VideoDownloader = reporterModule.default;
|
|
69
|
+
});
|
|
70
|
+
(0, vitest_1.afterEach)(async () => {
|
|
71
|
+
const basePathToDelete = mockBasePath;
|
|
72
|
+
downloadVideoMock.mockReset();
|
|
73
|
+
mockBasePath = "";
|
|
74
|
+
getProviderClassMock.mockClear();
|
|
75
|
+
vitest_1.vi.useRealTimers();
|
|
76
|
+
if (basePathToDelete) {
|
|
77
|
+
await promises_1.default.rm(basePathToDelete, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
(0, vitest_1.describe)("VideoDownloader", () => {
|
|
81
|
+
(0, vitest_1.test)("downloads device videos with session-scoped filename", async () => {
|
|
82
|
+
mockBasePath = await promises_1.default.mkdtemp(path_1.default.join(os_1.default.tmpdir(), "appwright-videos-"));
|
|
83
|
+
const sessionId = "session-123";
|
|
84
|
+
const testId = "test-abc";
|
|
85
|
+
downloadVideoMock.mockResolvedValueOnce({
|
|
86
|
+
path: path_1.default.join(mockBasePath, `${sessionId}-${testId}.mp4`),
|
|
87
|
+
contentType: "video/mp4",
|
|
88
|
+
});
|
|
89
|
+
const reporter = new VideoDownloader();
|
|
90
|
+
const testCase = {
|
|
91
|
+
id: testId,
|
|
92
|
+
title: "example",
|
|
93
|
+
annotations: [
|
|
94
|
+
{ type: "sessionId", description: sessionId },
|
|
95
|
+
{ type: "providerName", description: "browserstack" },
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
const testResult = {
|
|
99
|
+
workerIndex: 0,
|
|
100
|
+
duration: 1,
|
|
101
|
+
startTime: new Date(),
|
|
102
|
+
attachments: [],
|
|
103
|
+
};
|
|
104
|
+
reporter.onTestEnd(testCase, testResult);
|
|
105
|
+
(0, vitest_1.expect)(getProviderClassMock).toHaveBeenCalledWith("browserstack");
|
|
106
|
+
(0, vitest_1.expect)(downloadVideoMock).toHaveBeenCalledWith(sessionId, mockBasePath, `${sessionId}-${testId}`);
|
|
107
|
+
await reporter.onEnd();
|
|
108
|
+
(0, vitest_1.expect)(testResult.attachments).toEqual([
|
|
109
|
+
{
|
|
110
|
+
path: path_1.default.join(mockBasePath, `${sessionId}-${testId}.mp4`),
|
|
111
|
+
contentType: "video/mp4",
|
|
112
|
+
name: "video",
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
(0, vitest_1.test)("scopes persistentDevice worker video base name by sessionId", async () => {
|
|
117
|
+
vitest_1.vi.useFakeTimers();
|
|
118
|
+
mockBasePath = await promises_1.default.mkdtemp(path_1.default.join(os_1.default.tmpdir(), "appwright-videos-"));
|
|
119
|
+
const workerIndex = 0;
|
|
120
|
+
const sessionId = "session-xyz";
|
|
121
|
+
const providerName = "browserstack";
|
|
122
|
+
const workerVideoBaseName = `worker-${workerIndex}-${sessionId}-video`;
|
|
123
|
+
const workerStart = new Date("2025-01-01T00:00:00.000Z");
|
|
124
|
+
const testStart = new Date("2025-01-01T00:00:10.000Z");
|
|
125
|
+
await promises_1.default.writeFile(path_1.default.join(mockBasePath, `worker-info-${workerIndex}.json`), JSON.stringify({
|
|
126
|
+
idx: workerIndex,
|
|
127
|
+
sessionId,
|
|
128
|
+
providerName,
|
|
129
|
+
startTime: {
|
|
130
|
+
beforeAppiumSession: workerStart.toISOString(),
|
|
131
|
+
afterAppiumSession: workerStart.toISOString(),
|
|
132
|
+
},
|
|
133
|
+
endTime: new Date("2025-01-01T00:00:02.000Z").toISOString(),
|
|
134
|
+
tests: [],
|
|
135
|
+
}, null, 2));
|
|
136
|
+
const downloadedVideoPath = path_1.default.join(mockBasePath, `${workerVideoBaseName}.mp4`);
|
|
137
|
+
await promises_1.default.writeFile(downloadedVideoPath, "video-bytes");
|
|
138
|
+
downloadVideoMock.mockResolvedValueOnce({
|
|
139
|
+
path: downloadedVideoPath,
|
|
140
|
+
contentType: "video/mp4",
|
|
141
|
+
});
|
|
142
|
+
const reporter = new VideoDownloader();
|
|
143
|
+
const testCase = {
|
|
144
|
+
id: "test-1",
|
|
145
|
+
title: "persistent",
|
|
146
|
+
annotations: [],
|
|
147
|
+
};
|
|
148
|
+
const testResult = {
|
|
149
|
+
workerIndex,
|
|
150
|
+
duration: 1,
|
|
151
|
+
startTime: testStart,
|
|
152
|
+
retry: 1,
|
|
153
|
+
attachments: [],
|
|
154
|
+
};
|
|
155
|
+
reporter.onTestEnd(testCase, testResult);
|
|
156
|
+
await vitest_1.vi.advanceTimersByTimeAsync(5000);
|
|
157
|
+
await reporter.onEnd();
|
|
158
|
+
(0, vitest_1.expect)(downloadVideoMock).toHaveBeenCalledWith(sessionId, mockBasePath, workerVideoBaseName);
|
|
159
|
+
(0, vitest_1.expect)(testResult.attachments).toMatchObject([
|
|
160
|
+
{
|
|
161
|
+
contentType: "video/mp4",
|
|
162
|
+
name: "video",
|
|
163
|
+
path: vitest_1.expect.stringContaining(`-retry-1.mp4`),
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
});
|