@empiricalrun/playwright-utils 0.65.1 → 0.66.0
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 +7 -0
- package/dist/api-worker-client.d.ts +25 -6
- package/dist/api-worker-client.d.ts.map +1 -1
- package/dist/api-worker-client.js +73 -9
- package/dist/generated/api-worker-openapi.d.ts +479 -2
- package/dist/generated/api-worker-openapi.d.ts.map +1 -1
- package/dist/kv.d.ts +31 -1
- package/dist/kv.d.ts.map +1 -1
- package/dist/kv.js +79 -5
- package/dist/test/cross-shard-locks.d.ts +22 -0
- package/dist/test/cross-shard-locks.d.ts.map +1 -0
- package/dist/test/cross-shard-locks.js +110 -0
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +13 -0
- package/docs/kv.md +66 -2
- package/package.json +3 -3
- package/tsconfig.tsbuildinfo +1 -1
package/dist/kv.d.ts
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
|
+
import { type KvEntryRecord } from "./api-worker-client";
|
|
2
|
+
export type KvEntry<T = unknown> = Omit<KvEntryRecord, "value"> & {
|
|
3
|
+
value: T;
|
|
4
|
+
};
|
|
5
|
+
export type KvEntryMetadata = Omit<KvEntryRecord, "value">;
|
|
6
|
+
export type KvSetOptions = {
|
|
7
|
+
ifAbsent?: boolean;
|
|
8
|
+
ifVersion?: number;
|
|
9
|
+
};
|
|
10
|
+
export type KvListOptions = {
|
|
11
|
+
prefix?: string;
|
|
12
|
+
cursor?: string;
|
|
13
|
+
limit?: number;
|
|
14
|
+
};
|
|
15
|
+
export declare class KvPreconditionFailedError extends Error {
|
|
16
|
+
readonly current: KvEntry | null;
|
|
17
|
+
constructor(key: string, current: KvEntryRecord | null);
|
|
18
|
+
}
|
|
1
19
|
export declare class KVClient {
|
|
2
20
|
private client;
|
|
3
21
|
constructor();
|
|
4
22
|
get<T = unknown>(key: string): Promise<T | null>;
|
|
5
|
-
|
|
23
|
+
getEntry<T = unknown>(key: string): Promise<KvEntry<T> | null>;
|
|
24
|
+
set<T>(key: string, value: T, ttl: number, options?: KvSetOptions): Promise<KvEntry<T>>;
|
|
25
|
+
refresh<T = unknown>(key: string, ttl: number, options?: {
|
|
26
|
+
ifVersion?: number;
|
|
27
|
+
}): Promise<KvEntry<T> | null>;
|
|
28
|
+
delete(key: string, options?: {
|
|
29
|
+
ifVersion?: number;
|
|
30
|
+
}): Promise<boolean>;
|
|
31
|
+
list(options?: KvListOptions): Promise<{
|
|
32
|
+
entries: KvEntryMetadata[];
|
|
33
|
+
nextCursor: string | null;
|
|
34
|
+
}>;
|
|
35
|
+
entries<T = unknown>(prefix?: string): AsyncGenerator<KvEntry<T>>;
|
|
6
36
|
}
|
|
7
37
|
//# sourceMappingURL=kv.d.ts.map
|
package/dist/kv.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"kv.d.ts","sourceRoot":"","sources":["../src/kv.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"kv.d.ts","sourceRoot":"","sources":["../src/kv.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,aAAa,EAGnB,MAAM,qBAAqB,CAAC;AAE7B,MAAM,MAAM,OAAO,CAAC,CAAC,GAAG,OAAO,IAAI,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC,GAAG;IAAE,KAAK,EAAE,CAAC,CAAA;CAAE,CAAC;AAE/E,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC;AAE3D,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtE,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,qBAAa,yBAA0B,SAAQ,KAAK;IAClD,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAEjC,YAAY,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,EAQrD;CACF;AAoBD,qBAAa,QAAQ;IACnB,OAAO,CAAC,MAAM,CAAkB;IAEhC,cAEC;IAEK,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAGrD;IAEK,QAAQ,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAGnE;IAEK,GAAG,CAAC,CAAC,EACT,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,CAAC,EACR,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,YAAY,GACrB,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAUrB;IAEK,OAAO,CAAC,CAAC,GAAG,OAAO,EACvB,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAC/B,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAa5B;IAEK,MAAM,CACV,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAC/B,OAAO,CAAC,OAAO,CAAC,CAMlB;IAEK,IAAI,CACR,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC;QAAE,OAAO,EAAE,eAAe,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CASpE;IAEM,OAAO,CAAC,CAAC,GAAG,OAAO,EAAE,MAAM,SAAK,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAanE;CACF"}
|
package/dist/kv.js
CHANGED
|
@@ -1,18 +1,92 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.KVClient = void 0;
|
|
3
|
+
exports.KVClient = exports.KvPreconditionFailedError = void 0;
|
|
4
4
|
const api_worker_client_1 = require("./api-worker-client");
|
|
5
|
+
class KvPreconditionFailedError extends Error {
|
|
6
|
+
current;
|
|
7
|
+
constructor(key, current) {
|
|
8
|
+
super(current
|
|
9
|
+
? `KV condition failed for "${key}": it is at version ${current.version}`
|
|
10
|
+
: `KV condition failed for "${key}": it does not exist`);
|
|
11
|
+
this.name = "KvPreconditionFailedError";
|
|
12
|
+
this.current = current ? asEntry(current) : null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.KvPreconditionFailedError = KvPreconditionFailedError;
|
|
16
|
+
function asEntry(record) {
|
|
17
|
+
return { ...record, value: record.value };
|
|
18
|
+
}
|
|
19
|
+
function writeCondition(options) {
|
|
20
|
+
if (options?.ifAbsent && options.ifVersion !== undefined) {
|
|
21
|
+
throw new Error("Pass either ifAbsent or ifVersion, not both");
|
|
22
|
+
}
|
|
23
|
+
if (options?.ifAbsent) {
|
|
24
|
+
return { ifAbsent: true };
|
|
25
|
+
}
|
|
26
|
+
return options?.ifVersion === undefined
|
|
27
|
+
? undefined
|
|
28
|
+
: { ifVersion: options.ifVersion };
|
|
29
|
+
}
|
|
5
30
|
class KVClient {
|
|
6
31
|
client;
|
|
7
32
|
constructor() {
|
|
8
33
|
this.client = new api_worker_client_1.ApiWorkerClient();
|
|
9
34
|
}
|
|
10
35
|
async get(key) {
|
|
11
|
-
const
|
|
12
|
-
return
|
|
36
|
+
const entry = await this.getEntry(key);
|
|
37
|
+
return entry ? entry.value : null;
|
|
38
|
+
}
|
|
39
|
+
async getEntry(key) {
|
|
40
|
+
const record = await this.client.getKvEntry(key);
|
|
41
|
+
return record ? asEntry(record) : null;
|
|
42
|
+
}
|
|
43
|
+
async set(key, value, ttl, options) {
|
|
44
|
+
const result = await this.client.putKvEntry(key, { value: value, ttl }, writeCondition(options));
|
|
45
|
+
if (!result.ok) {
|
|
46
|
+
throw new KvPreconditionFailedError(key, result.current);
|
|
47
|
+
}
|
|
48
|
+
return asEntry(result.value);
|
|
49
|
+
}
|
|
50
|
+
async refresh(key, ttl, options) {
|
|
51
|
+
const result = await this.client.refreshKvEntry(key, ttl, options?.ifVersion);
|
|
52
|
+
if (result === null) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
throw new KvPreconditionFailedError(key, result.current);
|
|
57
|
+
}
|
|
58
|
+
return asEntry(result.value);
|
|
59
|
+
}
|
|
60
|
+
async delete(key, options) {
|
|
61
|
+
const result = await this.client.deleteKvEntry(key, options?.ifVersion);
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
throw new KvPreconditionFailedError(key, result.current);
|
|
64
|
+
}
|
|
65
|
+
return result.value;
|
|
66
|
+
}
|
|
67
|
+
async list(options = {}) {
|
|
68
|
+
const page = await this.client.listKvEntries({
|
|
69
|
+
...options,
|
|
70
|
+
include_values: "false",
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
entries: page.entries.map(({ value: _value, ...metadata }) => metadata),
|
|
74
|
+
nextCursor: page.next_cursor,
|
|
75
|
+
};
|
|
13
76
|
}
|
|
14
|
-
async
|
|
15
|
-
|
|
77
|
+
async *entries(prefix = "") {
|
|
78
|
+
let cursor;
|
|
79
|
+
do {
|
|
80
|
+
const page = await this.client.listKvEntries({
|
|
81
|
+
prefix,
|
|
82
|
+
cursor,
|
|
83
|
+
include_values: "true",
|
|
84
|
+
});
|
|
85
|
+
for (const record of page.entries) {
|
|
86
|
+
yield asEntry(record);
|
|
87
|
+
}
|
|
88
|
+
cursor = page.next_cursor ?? undefined;
|
|
89
|
+
} while (cursor);
|
|
16
90
|
}
|
|
17
91
|
}
|
|
18
92
|
exports.KVClient = KVClient;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type TestLocksByTestId } from "@empiricalrun/shared-types/playwright-utils";
|
|
2
|
+
import type { TestInfo } from "@playwright/test";
|
|
3
|
+
import { type KVClient } from "../kv";
|
|
4
|
+
type LockKv = Pick<KVClient, "set" | "refresh" | "delete">;
|
|
5
|
+
export declare const LOCK_LEASE_SECONDS = 60;
|
|
6
|
+
export declare function lockNamesFor(testId: string, locks?: TestLocksByTestId): string[];
|
|
7
|
+
export declare function lockKey(runId: string, name: string): string;
|
|
8
|
+
export declare function holdLocks({ kv, runId, names, holder, annotate, now, sleep, }: {
|
|
9
|
+
kv: LockKv;
|
|
10
|
+
runId: string;
|
|
11
|
+
names: string[];
|
|
12
|
+
holder: unknown;
|
|
13
|
+
annotate: (annotation: {
|
|
14
|
+
type: string;
|
|
15
|
+
description: string;
|
|
16
|
+
}) => void;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
}): Promise<() => Promise<void>>;
|
|
20
|
+
export declare function holdCrossShardLocks(testInfo: TestInfo, createKv: () => LockKv): Promise<() => Promise<void>>;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=cross-shard-locks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cross-shard-locks.d.ts","sourceRoot":"","sources":["../../src/test/cross-shard-locks.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,iBAAiB,EACvB,MAAM,6CAA6C,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD,OAAO,EAAE,KAAK,QAAQ,EAA6B,MAAM,OAAO,CAAC;AAEjE,KAAK,MAAM,GAAG,IAAI,CAAC,QAAQ,EAAE,KAAK,GAAG,SAAS,GAAG,QAAQ,CAAC,CAAC;AAI3D,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAkBrC,wBAAgB,YAAY,CAC1B,MAAM,EAAE,MAAM,EACd,KAAK,GAAE,iBACa,GACnB,MAAM,EAAE,CAEV;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAI3D;AAuBD,wBAAsB,SAAS,CAAC,EAC9B,EAAE,EACF,KAAK,EACL,KAAK,EACL,MAAM,EACN,QAAQ,EACR,GAAc,EACd,KAAiE,GAClE,EAAE;IACD,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,CAAC,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACtE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CA6C/B;AAED,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,QAAQ,EAClB,QAAQ,EAAE,MAAM,MAAM,GACrB,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAmB9B"}
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
exports.LOCK_LEASE_SECONDS = void 0;
|
|
7
|
+
exports.lockNamesFor = lockNamesFor;
|
|
8
|
+
exports.lockKey = lockKey;
|
|
9
|
+
exports.holdLocks = holdLocks;
|
|
10
|
+
exports.holdCrossShardLocks = holdCrossShardLocks;
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const playwright_utils_1 = require("@empiricalrun/shared-types/playwright-utils");
|
|
14
|
+
const kv_1 = require("../kv");
|
|
15
|
+
exports.LOCK_LEASE_SECONDS = 60;
|
|
16
|
+
const RENEW_EVERY_MS = 20_000;
|
|
17
|
+
const MAX_POLL_MS = 2_000;
|
|
18
|
+
let sharedLocks;
|
|
19
|
+
function readSharedLocks() {
|
|
20
|
+
const file = process.env[playwright_utils_1.TEST_LOCKS_FILE_ENV];
|
|
21
|
+
if (!file) {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(node_fs_1.default.readFileSync(file, "utf-8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function lockNamesFor(testId, locks = (sharedLocks ??=
|
|
32
|
+
readSharedLocks())) {
|
|
33
|
+
return [...new Set(locks[testId] ?? [])].sort();
|
|
34
|
+
}
|
|
35
|
+
function lockKey(runId, name) {
|
|
36
|
+
const readable = name.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 200);
|
|
37
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(name).digest("hex").slice(0, 12);
|
|
38
|
+
return `pw-lock:${runId}:${readable}:${digest}`;
|
|
39
|
+
}
|
|
40
|
+
async function acquire(kv, key, holder, sleep) {
|
|
41
|
+
for (let attempt = 0;; attempt++) {
|
|
42
|
+
try {
|
|
43
|
+
const entry = await kv.set(key, holder, exports.LOCK_LEASE_SECONDS, {
|
|
44
|
+
ifAbsent: true,
|
|
45
|
+
});
|
|
46
|
+
return entry.version;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!(error instanceof kv_1.KvPreconditionFailedError)) {
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
await sleep(Math.min(MAX_POLL_MS, 250 * 2 ** attempt));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function holdLocks({ kv, runId, names, holder, annotate, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), }) {
|
|
57
|
+
const held = [];
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const key = lockKey(runId, name);
|
|
60
|
+
const startedAt = now();
|
|
61
|
+
const version = await acquire(kv, key, holder, sleep);
|
|
62
|
+
held.push({ name, key, version });
|
|
63
|
+
annotate({
|
|
64
|
+
type: "lock",
|
|
65
|
+
description: `${name}: waited ${Math.round((now() - startedAt) / 1000)}s across shards`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const leaseLost = (lock) => annotate({ type: "lock", description: `${lock.name}: lease lost` });
|
|
69
|
+
let renewing = Promise.resolve();
|
|
70
|
+
const renewal = setInterval(() => {
|
|
71
|
+
renewing = Promise.allSettled(held.map((lock) => kv
|
|
72
|
+
.refresh(lock.key, exports.LOCK_LEASE_SECONDS, { ifVersion: lock.version })
|
|
73
|
+
.then((entry) => {
|
|
74
|
+
if (entry) {
|
|
75
|
+
lock.version = entry.version;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
leaseLost(lock);
|
|
79
|
+
}
|
|
80
|
+
}, () => leaseLost(lock))));
|
|
81
|
+
}, RENEW_EVERY_MS);
|
|
82
|
+
renewal.unref();
|
|
83
|
+
return async () => {
|
|
84
|
+
clearInterval(renewal);
|
|
85
|
+
await renewing;
|
|
86
|
+
await Promise.allSettled(held
|
|
87
|
+
.reverse()
|
|
88
|
+
.map((lock) => kv.delete(lock.key, { ifVersion: lock.version })));
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async function holdCrossShardLocks(testInfo, createKv) {
|
|
92
|
+
const names = lockNamesFor(testInfo.testId);
|
|
93
|
+
const runId = process.env.TEST_RUN_GITHUB_ACTION_ID;
|
|
94
|
+
if (names.length === 0 || !runId || !process.env.EMPIRICALRUN_API_KEY) {
|
|
95
|
+
return async () => { };
|
|
96
|
+
}
|
|
97
|
+
return holdLocks({
|
|
98
|
+
kv: createKv(),
|
|
99
|
+
runId,
|
|
100
|
+
names,
|
|
101
|
+
holder: {
|
|
102
|
+
test_id: testInfo.testId,
|
|
103
|
+
title: testInfo.titlePath.join(" › "),
|
|
104
|
+
retry: testInfo.retry,
|
|
105
|
+
shard: testInfo.config.shard?.current ?? null,
|
|
106
|
+
worker_index: testInfo.workerIndex,
|
|
107
|
+
},
|
|
108
|
+
annotate: (annotation) => testInfo.annotations.push(annotation),
|
|
109
|
+
});
|
|
110
|
+
}
|
package/dist/test/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ type TestOptions = {
|
|
|
21
21
|
page: Page;
|
|
22
22
|
}>;
|
|
23
23
|
saveVideos: void;
|
|
24
|
+
crossShardLocks: void;
|
|
24
25
|
};
|
|
25
26
|
declare const baseTestFixture: (testFn: TestType<PlaywrightTestArgs & PlaywrightTestOptions, PlaywrightWorkerArgs & PlaywrightWorkerOptions>, options?: BaseTestFixtureOpts) => TestType<PlaywrightTestArgs & PlaywrightTestOptions & TestOptions, PlaywrightWorkerArgs & PlaywrightWorkerOptions>;
|
|
26
27
|
export { baseTestFixture, extendExpect, injectLocatorHighlightScripts, setVideoLabel, };
|
package/dist/test/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/test/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,qBAAqB,EACrB,MAAM,EACN,IAAI,EACJ,kBAAkB,EAClB,qBAAqB,EACrB,oBAAoB,EACpB,uBAAuB,EACvB,QAAQ,EACT,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/test/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,qBAAqB,EACrB,MAAM,EACN,IAAI,EACJ,kBAAkB,EAClB,qBAAqB,EACrB,oBAAoB,EACpB,uBAAuB,EACvB,QAAQ,EACT,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAGjC,OAAO,EAGL,KAAK,qBAAqB,EAC3B,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,6BAA6B,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,KAAK,mBAAmB,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAA8B,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE3E,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,cAAc,CAAC;QACvB,UAAU,QAAQ,CAAC,CAAC;YAClB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;YAC7C,qBAAqB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;SACnE;KACF;CACF;AAED,QAAA,MAAM,YAAY,mBAA6B,OAAO,MAAM,0CAG3D,CAAC;AAEF,KAAK,WAAW,GAAG;IACjB,IAAI,EAAE,IAAI,CAAC;IACX,EAAE,EAAE,QAAQ,CAAC;IACb,yBAAyB,EAAE,CACzB,OAAO,CAAC,EAAE,qBAAqB,KAC5B,OAAO,CAAC;QAAE,OAAO,EAAE,cAAc,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAE,CAAC,CAAC;IACtD,UAAU,EAAE,IAAI,CAAC;IACjB,eAAe,EAAE,IAAI,CAAC;CACvB,CAAC;AAIF,QAAA,MAAM,eAAe,WACX,QAAQ,CACd,kBAAkB,GAAG,qBAAqB,EAC1C,oBAAoB,GAAG,uBAAuB,CAC/C,YACQ,mBAAmB,uHA2H7B,CAAC;AAEF,OAAO,EACL,eAAe,EACf,YAAY,EACZ,6BAA6B,EAC7B,aAAa,GACd,CAAC"}
|
package/dist/test/index.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.setVideoLabel = exports.injectLocatorHighlightScripts = exports.extendEx
|
|
|
7
7
|
const path_1 = __importDefault(require("path"));
|
|
8
8
|
const kv_1 = require("../kv");
|
|
9
9
|
const coverage_1 = require("./coverage");
|
|
10
|
+
const cross_shard_locks_1 = require("./cross-shard-locks");
|
|
10
11
|
const expect_1 = require("./expect");
|
|
11
12
|
const scripts_1 = require("./scripts");
|
|
12
13
|
Object.defineProperty(exports, "injectLocatorHighlightScripts", { enumerable: true, get: function () { return scripts_1.injectLocatorHighlightScripts; } });
|
|
@@ -112,6 +113,18 @@ const baseTestFixture = function (testFn, options = {}) {
|
|
|
112
113
|
},
|
|
113
114
|
{ auto: true },
|
|
114
115
|
],
|
|
116
|
+
crossShardLocks: [
|
|
117
|
+
async ({}, use, testInfo) => {
|
|
118
|
+
const release = await (0, cross_shard_locks_1.holdCrossShardLocks)(testInfo, () => new kv_1.KVClient());
|
|
119
|
+
try {
|
|
120
|
+
await use();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await release();
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{ auto: true, timeout: 0 },
|
|
127
|
+
],
|
|
115
128
|
});
|
|
116
129
|
return extendedTestFn;
|
|
117
130
|
};
|
package/docs/kv.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# KV Store
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A project-scoped key-value store for sharing state across tests, shards, test
|
|
4
|
+
runs and agent sessions. Every entry expires, and conditional writes make it
|
|
5
|
+
safe to coordinate between parallel shards.
|
|
4
6
|
|
|
5
7
|
## Fixture usage
|
|
6
8
|
|
|
@@ -17,10 +19,72 @@ test("example", async ({ page, kv }) => {
|
|
|
17
19
|
import { KVClient } from "@empiricalrun/playwright-utils";
|
|
18
20
|
|
|
19
21
|
const kv = new KVClient();
|
|
20
|
-
await kv.set("my-key", { some: "data" }, 3600);
|
|
22
|
+
await kv.set("my-key", { some: "data" }, 3600);
|
|
21
23
|
const value = await kv.get<{ some: string }>("my-key");
|
|
22
24
|
```
|
|
23
25
|
|
|
26
|
+
## Keys, values and expiry
|
|
27
|
+
|
|
28
|
+
- Keys are 1-512 characters from `A-Z a-z 0-9 . _ : -`. Use `:` to namespace
|
|
29
|
+
them, e.g. `async-checks:analytics:1234`.
|
|
30
|
+
- Values are any JSON value, up to 1 MB serialized.
|
|
31
|
+
- `ttl` is required, in seconds, up to 30 days.
|
|
32
|
+
|
|
33
|
+
## Entries and versions
|
|
34
|
+
|
|
35
|
+
`getEntry` returns the value with its metadata. `version` increases on every
|
|
36
|
+
write.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const entry = await kv.getEntry("my-key");
|
|
40
|
+
// { key, value, version, expires_at, created_at, updated_at, written_by }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Conditional writes
|
|
44
|
+
|
|
45
|
+
`set` with `ifAbsent` writes only if the key does not exist; `ifVersion` writes
|
|
46
|
+
only if the entry is still at that version. A failed condition throws
|
|
47
|
+
`KvPreconditionFailedError`, whose `current` is the entry as it is now.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { KvPreconditionFailedError } from "@empiricalrun/playwright-utils";
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const lease = await kv.set("lock:checkout", testInfo.testId, 60, {
|
|
54
|
+
ifAbsent: true,
|
|
55
|
+
});
|
|
56
|
+
await kv.refresh("lock:checkout", 60, { ifVersion: lease.version });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof KvPreconditionFailedError) {
|
|
59
|
+
console.log("held by", error.current?.value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Refresh, delete and list
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
await kv.refresh("my-key", 600);
|
|
68
|
+
await kv.delete("my-key", { ifVersion: 3 });
|
|
69
|
+
|
|
70
|
+
const page = await kv.list({ prefix: "async-checks:" });
|
|
71
|
+
for await (const entry of kv.entries("async-checks:")) {
|
|
72
|
+
console.log(entry.key, entry.value);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`refresh` returns `null` and `delete` returns `false` when the key does not
|
|
77
|
+
exist. `list` returns metadata only; `entries` pages through every entry with
|
|
78
|
+
its value.
|
|
79
|
+
|
|
80
|
+
## Locks across shards
|
|
81
|
+
|
|
82
|
+
Tests declared with Playwright's `lock` option (`test("...", { lock: "name" }, ...)`)
|
|
83
|
+
also hold that lock across every shard of an Empirical test run. The
|
|
84
|
+
`baseTestFixture` fixtures take a KV lease per lock name while the test runs and
|
|
85
|
+
add a `lock` annotation with the time spent waiting. Locks are scoped to the
|
|
86
|
+
test run.
|
|
87
|
+
|
|
24
88
|
## Requirements
|
|
25
89
|
|
|
26
90
|
- `EMPIRICALRUN_API_KEY` environment variable must be set
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@empiricalrun/playwright-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22"
|
|
6
6
|
},
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"openapi-fetch": "0.14.1",
|
|
57
57
|
"puppeteer-extra-plugin-recaptcha": "^3.6.8",
|
|
58
58
|
"ts-morph": "^28.0.0",
|
|
59
|
-
"@empiricalrun/cua": "^0.6.0",
|
|
60
59
|
"@empiricalrun/r2-uploader": "^0.11.5",
|
|
61
|
-
"@empiricalrun/reporter": "^0.30.3"
|
|
60
|
+
"@empiricalrun/reporter": "^0.30.3",
|
|
61
|
+
"@empiricalrun/cua": "^0.6.0"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
64
|
"dev": "pnpm gen:api-worker-openapi && tsc --build --watch",
|