@le-space/playwright 0.6.28 → 0.6.30

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.
Files changed (4) hide show
  1. package/README.md +26 -0
  2. package/index.d.ts +86 -2
  3. package/index.js +190 -50
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -17,3 +17,29 @@ import {
17
17
 
18
18
  `@playwright/test` is a peer dependency. Use a compatible 1.61.x client; remote
19
19
  Playwright servers must use the exact same client/server version.
20
+
21
+ ## Aleph remote Chromium
22
+
23
+ `connectAlephChromium()` is the thin consumer-facing adapter for the dedicated
24
+ Aleph runner. It authenticates the `/version` probe and websocket with the same
25
+ per-run bearer secret, requires HTTPS/WSS, checks the guest is exactly
26
+ Playwright `1.61.1`, and only then calls `chromium.connect()`.
27
+
28
+ ```ts
29
+ const browser = await connectAlephChromium({
30
+ chromium,
31
+ wsEndpoint,
32
+ versionUrl,
33
+ secret,
34
+ })
35
+ ```
36
+
37
+ `buildAlephCostEvidence()` and `formatAlephCostGithubSummary()` keep required
38
+ credit capacity separate from the authoritative before/after account balance
39
+ delta. A balance delta is attributable to one run only when that deployment
40
+ account is not being used concurrently.
41
+
42
+ `selectExpiredAlephPlaywrightRunners()` is the safety boundary used by the
43
+ retention janitor. The repository script requires an explicit repository scope
44
+ and owner key, then erases, owner-signs FORGET for, and verifies each selected
45
+ exact INSTANCE hash. It never cleans by display name alone.
package/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as _playwright_test from '@playwright/test';
2
- import { Page, Locator, BrowserContext } from '@playwright/test';
2
+ import { Browser, Page, Locator, BrowserContext } from '@playwright/test';
3
3
  import { RelayBootstrapPostRecord, RelayBootstrapContent, fetchAlephBootstrapPosts } from '@le-space/aleph-bootstrap';
4
4
  import { eraseInstanceOnCrn, forgetAlephMessages } from '@le-space/core';
5
5
 
6
+ declare const PLAYWRIGHT_RUNNER_VERSION = "1.61.1";
6
7
  declare const SUPPORTED_ALEPH_API_HOSTS: readonly ["https://api2.aleph.im", "https://api.aleph.im"];
7
8
  declare const DEFAULT_ALEPH_SCHEDULER_URL = "https://scheduler.api.aleph.cloud";
8
9
  interface RelayWalletAccount {
@@ -28,6 +29,63 @@ interface RelayEvidence {
28
29
  error?: string;
29
30
  [key: string]: unknown;
30
31
  }
32
+ interface AlephChromiumConnector {
33
+ connect(wsEndpoint: string, options?: {
34
+ headers?: Record<string, string>;
35
+ timeout?: number;
36
+ }): Promise<Browser>;
37
+ }
38
+ interface AlephRemoteBrowserOptions {
39
+ chromium: AlephChromiumConnector;
40
+ wsEndpoint: string;
41
+ versionUrl: string;
42
+ secret: string;
43
+ expectedVersion?: string;
44
+ timeoutMs?: number;
45
+ fetch?: typeof fetch;
46
+ }
47
+ interface AlephCreditBalanceSnapshot {
48
+ capturedAt: string;
49
+ apiHost: string;
50
+ creditBalance: number;
51
+ lockedAmount: number;
52
+ }
53
+ interface AlephPricingSnapshot {
54
+ capturedAt: string;
55
+ apiHost: string;
56
+ unitCredit: number;
57
+ computeUnits: number;
58
+ vcpus: number;
59
+ memoryMiB: number;
60
+ diskMiB: number;
61
+ }
62
+ interface AlephCostEvidence {
63
+ paymentType: 'credit';
64
+ startedAt: string;
65
+ finishedAt: string;
66
+ runtimeSeconds: number;
67
+ pricing: AlephPricingSnapshot;
68
+ before: AlephCreditBalanceSnapshot;
69
+ after: AlephCreditBalanceSnapshot;
70
+ requiredCredits: number;
71
+ netAccountCreditDelta: number;
72
+ creditsConsumed: number;
73
+ creditsReturned: number;
74
+ accountingNote: string;
75
+ }
76
+ interface AlephRunnerInstanceCandidate {
77
+ itemHash: string;
78
+ ownerAddress: string;
79
+ instanceName: string;
80
+ createdAt: string;
81
+ status: string;
82
+ }
83
+ interface AlephRunnerJanitorSelection {
84
+ expired: AlephRunnerInstanceCandidate[];
85
+ retained: Array<AlephRunnerInstanceCandidate & {
86
+ reason: string;
87
+ }>;
88
+ }
31
89
  interface RelayAddressPolicy {
32
90
  allowWebTransport?: boolean;
33
91
  allowWebRtcDirect?: boolean;
@@ -81,6 +139,8 @@ interface CleanupRelayOptions {
81
139
  account: RelayWalletAccount;
82
140
  instanceName: string;
83
141
  instanceHash: string;
142
+ /** Guest-published relay-bootstrap POST to verify after graceful VM shutdown. */
143
+ registrationHash?: string;
84
144
  driver?: RelayButtonDriver;
85
145
  apiHosts?: readonly string[];
86
146
  schedulerUrl?: string;
@@ -99,6 +159,7 @@ interface CleanupRelayResult {
99
159
  eraseSummary: string;
100
160
  forgetSummary: string;
101
161
  verificationSummary: string;
162
+ registrationVerificationSummary?: string;
102
163
  }
103
164
  interface CleanupRelayHooks {
104
165
  erase?: typeof eraseInstanceOnCrn;
@@ -116,6 +177,22 @@ interface CreateRelayTestOptions {
116
177
  cleanup?: Omit<CleanupRelayOptions, 'page' | 'account' | 'instanceName' | 'instanceHash'>;
117
178
  }
118
179
  declare function resolveAlephApiHosts(candidates?: readonly string[]): string[];
180
+ declare function connectAlephChromium(options: AlephRemoteBrowserOptions): Promise<Browser>;
181
+ declare function buildAlephCostEvidence(options: {
182
+ startedAt: string;
183
+ finishedAt: string;
184
+ pricing: AlephPricingSnapshot;
185
+ before: AlephCreditBalanceSnapshot;
186
+ after: AlephCreditBalanceSnapshot;
187
+ }): AlephCostEvidence;
188
+ declare function formatAlephCostGithubSummary(cost: AlephCostEvidence): string;
189
+ declare function selectExpiredAlephPlaywrightRunners(options: {
190
+ candidates: readonly AlephRunnerInstanceCandidate[];
191
+ ownerAddress: string;
192
+ repository: string;
193
+ now?: number;
194
+ ttlMs?: number;
195
+ }): AlephRunnerJanitorSelection;
119
196
  declare function waitForPubsubSubscriber(page: Page, options: WaitForPubsubSubscriberOptions): Promise<string[]>;
120
197
  declare function installEip1193WalletMock(context: BrowserContext, account: RelayWalletAccount): Promise<void>;
121
198
  declare class RelayButtonDriver {
@@ -163,6 +240,13 @@ declare function waitForAlephInstanceDeletion(options: {
163
240
  pollIntervalMs?: number;
164
241
  fetch?: typeof fetch;
165
242
  }): Promise<string>;
243
+ declare function waitForAlephMessageForgotten(options: {
244
+ messageHash: string;
245
+ apiHosts?: readonly string[];
246
+ timeoutMs?: number;
247
+ pollIntervalMs?: number;
248
+ fetch?: typeof fetch;
249
+ }): Promise<string>;
166
250
  declare function cleanupRelay(options: CleanupRelayOptions): Promise<CleanupRelayResult>;
167
251
  declare function createRelayEvidence(options: {
168
252
  instanceName: string;
@@ -181,4 +265,4 @@ declare function createRelayTest(options: CreateRelayTestOptions): _playwright_t
181
265
  relayLifecycle: RelayLifecycleFixture;
182
266
  }, _playwright_test.PlaywrightWorkerArgs & _playwright_test.PlaywrightWorkerOptions>;
183
267
 
184
- export { type CleanupRelayHooks, type CleanupRelayOptions, type CleanupRelayResult, type CreateRelayTestOptions, DEFAULT_ALEPH_SCHEDULER_URL, type ProvisionRelayOptions, type ProvisionedRelay, type RelayAddressPolicy, RelayButtonDriver, type RelayButtonDriverOptions, type RelayEvidence, type RelayEvidenceStep, type RelayLifecycleFixture, type RelayWalletAccount, SUPPORTED_ALEPH_API_HOSTS, type WaitForPubsubSubscriberOptions, appendRelayGithubSummary, cleanupRelay, createRelayEvidence, createRelayTest, findAlephInstanceHash, formatRelayGithubSummary, installEip1193WalletMock, provisionRelay, resolveAlephApiHosts, selectBrowserRelayAddresses, updateRelayEvidenceStep, waitForAlephInstanceDeletion, waitForBootstrapRegistration, waitForDeployableManifest, waitForPubsubSubscriber, writeRelayEvidence };
268
+ export { type AlephChromiumConnector, type AlephCostEvidence, type AlephCreditBalanceSnapshot, type AlephPricingSnapshot, type AlephRemoteBrowserOptions, type AlephRunnerInstanceCandidate, type AlephRunnerJanitorSelection, type CleanupRelayHooks, type CleanupRelayOptions, type CleanupRelayResult, type CreateRelayTestOptions, DEFAULT_ALEPH_SCHEDULER_URL, PLAYWRIGHT_RUNNER_VERSION, type ProvisionRelayOptions, type ProvisionedRelay, type RelayAddressPolicy, RelayButtonDriver, type RelayButtonDriverOptions, type RelayEvidence, type RelayEvidenceStep, type RelayLifecycleFixture, type RelayWalletAccount, SUPPORTED_ALEPH_API_HOSTS, type WaitForPubsubSubscriberOptions, appendRelayGithubSummary, buildAlephCostEvidence, cleanupRelay, connectAlephChromium, createRelayEvidence, createRelayTest, findAlephInstanceHash, formatAlephCostGithubSummary, formatRelayGithubSummary, installEip1193WalletMock, provisionRelay, resolveAlephApiHosts, selectBrowserRelayAddresses, selectExpiredAlephPlaywrightRunners, updateRelayEvidenceStep, waitForAlephInstanceDeletion, waitForAlephMessageForgotten, waitForBootstrapRegistration, waitForDeployableManifest, waitForPubsubSubscriber, writeRelayEvidence };
package/index.js CHANGED
@@ -2,17 +2,11 @@
2
2
  import { createHash } from "crypto";
3
3
  import { appendFile, mkdir, writeFile } from "fs/promises";
4
4
  import { dirname } from "path";
5
- import {
6
- fetchAlephBootstrapPosts
7
- } from "@le-space/aleph-bootstrap";
5
+ import { fetchAlephBootstrapPosts } from "@le-space/aleph-bootstrap";
8
6
  import { eraseInstanceOnCrn, forgetAlephMessages } from "@le-space/core";
9
- import {
10
- test as playwrightTest
11
- } from "@playwright/test";
12
- var SUPPORTED_ALEPH_API_HOSTS = [
13
- "https://api2.aleph.im",
14
- "https://api.aleph.im"
15
- ];
7
+ import { test as playwrightTest } from "@playwright/test";
8
+ var PLAYWRIGHT_RUNNER_VERSION = "1.61.1";
9
+ var SUPPORTED_ALEPH_API_HOSTS = ["https://api2.aleph.im", "https://api.aleph.im"];
16
10
  var DEFAULT_ALEPH_SCHEDULER_URL = "https://scheduler.api.aleph.cloud";
17
11
  var API3_HOST_PATTERN = /(^|\.)api3\.aleph\.im$/iu;
18
12
  function normalizeApiOrigin(value) {
@@ -25,12 +19,118 @@ function normalizeApiOrigin(value) {
25
19
  }
26
20
  }
27
21
  function resolveAlephApiHosts(candidates) {
28
- const allowed = new Set(
29
- (candidates ?? SUPPORTED_ALEPH_API_HOSTS).map(normalizeApiOrigin).filter((value) => value != null)
30
- );
22
+ const allowed = new Set((candidates ?? SUPPORTED_ALEPH_API_HOSTS).map(normalizeApiOrigin).filter((value) => value != null));
31
23
  const selected = SUPPORTED_ALEPH_API_HOSTS.filter((host) => allowed.has(host));
32
24
  return selected.length > 0 ? [...selected] : [...SUPPORTED_ALEPH_API_HOSTS];
33
25
  }
26
+ function requireFiniteNumber(value, label) {
27
+ const number = Number(value);
28
+ if (!Number.isFinite(number)) throw new Error(`${label} must be a finite number`);
29
+ return number;
30
+ }
31
+ async function connectAlephChromium(options) {
32
+ const expectedVersion = options.expectedVersion ?? PLAYWRIGHT_RUNNER_VERSION;
33
+ const secret = options.secret.trim();
34
+ if (!secret) throw new Error("Aleph Playwright secret is required");
35
+ if (!options.wsEndpoint.startsWith("wss://")) {
36
+ throw new Error("Aleph Playwright endpoint must use authenticated WSS");
37
+ }
38
+ if (!options.versionUrl.startsWith("https://")) {
39
+ throw new Error("Aleph Playwright version endpoint must use HTTPS");
40
+ }
41
+ const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
42
+ if (!fetchImpl) throw new Error("A fetch implementation is required for version verification");
43
+ const authorization = `Bearer ${secret}`;
44
+ const response = await fetchImpl(options.versionUrl, {
45
+ headers: { Authorization: authorization },
46
+ cache: "no-store",
47
+ signal: AbortSignal.timeout(options.timeoutMs ?? 3e4)
48
+ });
49
+ if (!response.ok) {
50
+ throw new Error(`Aleph Playwright version endpoint returned HTTP ${response.status}`);
51
+ }
52
+ const payload = await response.json();
53
+ const actualVersion = String(payload.playwrightVersion ?? "");
54
+ if (actualVersion !== expectedVersion) {
55
+ throw new Error(`Playwright client/server version mismatch: client ${expectedVersion}, guest ${actualVersion || "unknown"}`);
56
+ }
57
+ return options.chromium.connect(options.wsEndpoint, {
58
+ headers: { Authorization: authorization },
59
+ timeout: options.timeoutMs ?? 3e4
60
+ });
61
+ }
62
+ function buildAlephCostEvidence(options) {
63
+ const startedAtMs = Date.parse(options.startedAt);
64
+ const finishedAtMs = Date.parse(options.finishedAt);
65
+ if (!Number.isFinite(startedAtMs) || !Number.isFinite(finishedAtMs) || finishedAtMs < startedAtMs) {
66
+ throw new Error("Aleph cost evidence requires ordered ISO start/finish timestamps");
67
+ }
68
+ const unitCredit = requireFiniteNumber(options.pricing.unitCredit, "unitCredit");
69
+ const computeUnits = requireFiniteNumber(options.pricing.computeUnits, "computeUnits");
70
+ const beforeCredits = requireFiniteNumber(options.before.creditBalance, "before.creditBalance");
71
+ const afterCredits = requireFiniteNumber(options.after.creditBalance, "after.creditBalance");
72
+ const netAccountCreditDelta = afterCredits - beforeCredits;
73
+ return {
74
+ paymentType: "credit",
75
+ startedAt: options.startedAt,
76
+ finishedAt: options.finishedAt,
77
+ runtimeSeconds: Math.ceil((finishedAtMs - startedAtMs) / 1e3),
78
+ pricing: options.pricing,
79
+ before: options.before,
80
+ after: options.after,
81
+ requiredCredits: unitCredit * computeUnits,
82
+ netAccountCreditDelta,
83
+ creditsConsumed: Math.max(0, -netAccountCreditDelta),
84
+ creditsReturned: Math.max(0, netAccountCreditDelta),
85
+ accountingNote: "Required credits are Aleph credit-payment capacity, not a time-pro-rated charge. The balance delta is the authoritative account observation for this interval and is attributable to this test only when the deployment account is not used concurrently."
86
+ };
87
+ }
88
+ function formatAlephCostGithubSummary(cost) {
89
+ return `## Aleph remote browser cost
90
+
91
+ | Field | Value |
92
+ | --- | ---: |
93
+ | Payment type | ${cost.paymentType} |
94
+ | Runtime | ${cost.runtimeSeconds} s |
95
+ | Hardware | ${cost.pricing.vcpus} vCPU \xB7 ${cost.pricing.memoryMiB} MiB RAM \xB7 ${cost.pricing.diskMiB} MiB disk |
96
+ | Compute units | ${cost.pricing.computeUnits} |
97
+ | Unit credit requirement | ${cost.pricing.unitCredit} credits |
98
+ | Required credit capacity | ${cost.requiredCredits} credits |
99
+ | Balance before | ${cost.before.creditBalance} credits |
100
+ | Balance after cleanup | ${cost.after.creditBalance} credits |
101
+ | Net account delta | ${cost.netAccountCreditDelta} credits |
102
+ | Credits consumed | ${cost.creditsConsumed} credits |
103
+ | Credits returned | ${cost.creditsReturned} credits |
104
+
105
+ Pricing source: \`${cost.pricing.apiHost}\` at ${cost.pricing.capturedAt}. Balance sources: \`${cost.before.apiHost}\` and \`${cost.after.apiHost}\`.
106
+
107
+ > ${cost.accountingNote}
108
+ `;
109
+ }
110
+ function selectExpiredAlephPlaywrightRunners(options) {
111
+ const owner = options.ownerAddress.trim().toLowerCase();
112
+ const repository = options.repository.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
113
+ if (!/^0x[a-f0-9]{40}$/u.test(owner)) throw new Error("Janitor requires an exact EVM owner address");
114
+ if (!repository) throw new Error("Janitor requires a repository name");
115
+ const prefix = `playwright-${repository}-`;
116
+ const now = options.now ?? Date.now();
117
+ const ttlMs = options.ttlMs ?? 60 * 6e4;
118
+ if (!Number.isFinite(ttlMs) || ttlMs < 15 * 6e4) throw new Error("Janitor TTL must be at least 15 minutes");
119
+ const selection = { expired: [], retained: [] };
120
+ for (const candidate of options.candidates) {
121
+ let reason = "";
122
+ const createdAt = Date.parse(candidate.createdAt);
123
+ if (!/^[a-f0-9]{64}$/iu.test(candidate.itemHash)) reason = "invalid exact INSTANCE hash";
124
+ else if (candidate.ownerAddress.toLowerCase() !== owner) reason = "different owner";
125
+ else if (!candidate.instanceName.toLowerCase().startsWith(prefix)) reason = "name outside repository scope";
126
+ else if (!Number.isFinite(createdAt)) reason = "invalid creation timestamp";
127
+ else if (now - createdAt < ttlMs) reason = "within TTL";
128
+ else if (!["processed", "pending"].includes(candidate.status.toLowerCase())) reason = `terminal status ${candidate.status}`;
129
+ if (reason) selection.retained.push({ ...candidate, reason });
130
+ else selection.expired.push(candidate);
131
+ }
132
+ return selection;
133
+ }
34
134
  async function waitForPubsubSubscriber(page, options) {
35
135
  const timeoutMs = options.timeoutMs ?? 3e4;
36
136
  const pollIntervalMs = options.pollIntervalMs ?? 250;
@@ -72,13 +172,13 @@ async function installEip1193WalletMock(context, account) {
72
172
  case "eth_chainId":
73
173
  return "0x1";
74
174
  case "personal_sign": {
75
- const payload = params.find(
76
- (value) => typeof value === "string" && value.startsWith("0x") && value.toLowerCase() !== account.address.toLowerCase()
77
- );
175
+ const payload = params.find((value) => typeof value === "string" && value.startsWith("0x") && value.toLowerCase() !== account.address.toLowerCase());
78
176
  if (typeof payload !== "string") {
79
177
  throw new Error("personal_sign did not contain a payload");
80
178
  }
81
- return account.signMessage({ message: { raw: payload } });
179
+ return account.signMessage({
180
+ message: { raw: payload }
181
+ });
82
182
  }
83
183
  default:
84
184
  throw new Error(`Unsupported E2E wallet method: ${method ?? "missing"}`);
@@ -119,19 +219,26 @@ var RelayButtonDriver = class {
119
219
  };
120
220
  }
121
221
  deployButton() {
122
- return this.page.getByRole("button", { name: this.options.deployButtonName });
222
+ return this.page.getByRole("button", {
223
+ name: this.options.deployButtonName
224
+ });
123
225
  }
124
226
  instance(instanceName) {
125
227
  return this.page.locator("details").filter({ hasText: instanceName }).first();
126
228
  }
127
229
  async prepare(options) {
128
- const launcher = this.page.getByRole("button", { name: this.options.launcherName });
230
+ const launcher = this.page.getByRole("button", {
231
+ name: this.options.launcherName
232
+ });
129
233
  await launcher.waitFor({ state: "visible", timeout: 6e4 });
130
234
  await launcher.click();
131
235
  await this.page.getByPlaceholder(this.options.instanceNamePlaceholder).fill(options.instanceName);
132
236
  await this.page.getByText("Advanced", { exact: true }).click();
133
237
  await this.page.getByPlaceholder(this.options.sshPublicKeyPlaceholder).fill(options.sshPublicKey);
134
- await this.page.getByRole("button", { name: this.options.connectWalletName, exact: true }).click();
238
+ await this.page.getByRole("button", {
239
+ name: this.options.connectWalletName,
240
+ exact: true
241
+ }).click();
135
242
  }
136
243
  async requestDelete(instanceName) {
137
244
  await this.page.getByRole("button", { name: this.options.refreshButtonName }).click().catch(() => {
@@ -154,9 +261,7 @@ async function waitForDeployableManifest(page, options = {}) {
154
261
  const panelText = document.querySelector("aside")?.textContent ?? document.body.textContent ?? "";
155
262
  const failure = states.find((state) => panelText.includes(state));
156
263
  if (failure) return { status: "error", message: failure };
157
- const deployButton = [...document.querySelectorAll("button")].find(
158
- (button) => button.textContent?.trim() === "Deploy Relay"
159
- );
264
+ const deployButton = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Deploy Relay");
160
265
  return deployButton && !deployButton.disabled ? { status: "ready" } : null;
161
266
  },
162
267
  { states: terminalStates },
@@ -164,18 +269,14 @@ async function waitForDeployableManifest(page, options = {}) {
164
269
  );
165
270
  const result = await outcome.jsonValue();
166
271
  if (result?.status === "error") {
167
- throw new Error(
168
- `Relay Button manifest is not deployable: ${result.message}. Republish the rootfs and update the manifest before provisioning.`
169
- );
272
+ throw new Error(`Relay Button manifest is not deployable: ${result.message}. Republish the rootfs and update the manifest before provisioning.`);
170
273
  }
171
274
  }
172
275
  async function waitForDeploymentUi(page, instanceName, timeoutMs) {
173
276
  const outcome = await page.waitForFunction(
174
277
  (expectedName) => {
175
278
  const instance = [...document.querySelectorAll("details")].find(
176
- (element) => element.textContent?.includes(expectedName) && [...element.querySelectorAll("button")].some(
177
- (button) => button.textContent?.trim() === "Delete"
178
- )
279
+ (element) => element.textContent?.includes(expectedName) && [...element.querySelectorAll("button")].some((button) => button.textContent?.trim() === "Delete")
179
280
  );
180
281
  if (instance?.textContent?.includes("Aleph bootstrap registered")) {
181
282
  return { status: "instance" };
@@ -183,9 +284,7 @@ async function waitForDeploymentUi(page, instanceName, timeoutMs) {
183
284
  const error = document.querySelector("aside.panel .alert.error")?.textContent?.trim();
184
285
  if (error) return { status: "error", message: error };
185
286
  const panelText = document.querySelector("aside")?.textContent ?? "";
186
- const deployButton = [...document.querySelectorAll("button")].find(
187
- (button) => button.textContent?.includes("Deploy")
188
- );
287
+ const deployButton = [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Deploy"));
189
288
  if (panelText.includes("Deployment failed") && !deployButton?.textContent?.includes("Deploying")) {
190
289
  return { status: "error", message: panelText };
191
290
  }
@@ -310,7 +409,9 @@ async function provisionRelay(page, options) {
310
409
  instanceName: options.instanceName,
311
410
  sshPublicKey: options.sshPublicKey
312
411
  });
313
- await waitForDeployableManifest(page, { timeoutMs: options.manifestTimeoutMs });
412
+ await waitForDeployableManifest(page, {
413
+ timeoutMs: options.manifestTimeoutMs
414
+ });
314
415
  options.onPhase?.("wallet-and-manifest-ready");
315
416
  await driver.deployButton().click();
316
417
  options.onDeploymentSubmitted?.();
@@ -356,20 +457,14 @@ async function waitForAlephInstanceDeletion(options) {
356
457
  if (!fetchImpl) throw new Error("A fetch implementation is required for cleanup verification");
357
458
  const deadline = Date.now() + (options.timeoutMs ?? 5 * 6e4);
358
459
  const hosts = resolveAlephApiHosts(options.apiHosts);
359
- const schedulerUrl = new URL(
360
- `/api/v0/allocation/${options.instanceHash}`,
361
- options.schedulerUrl ?? DEFAULT_ALEPH_SCHEDULER_URL
362
- );
460
+ const schedulerUrl = new URL(`/api/v0/allocation/${options.instanceHash}`, options.schedulerUrl ?? DEFAULT_ALEPH_SCHEDULER_URL);
363
461
  let lastSummary = "Deletion has not been observed yet.";
364
462
  while (Date.now() < deadline) {
365
463
  const observations = [];
366
464
  let replicasForgotten = true;
367
465
  for (const apiHost of hosts) {
368
466
  try {
369
- const response = await fetchImpl(
370
- new URL(`/api/v0/messages/${options.instanceHash}`, apiHost),
371
- { cache: "no-cache" }
372
- );
467
+ const response = await fetchImpl(new URL(`/api/v0/messages/${options.instanceHash}`, apiHost), { cache: "no-cache" });
373
468
  const payload = await response.json().catch(() => null);
374
469
  const forgotten = payload?.status === "forgotten" || Boolean(payload?.forgotten_by?.length);
375
470
  replicasForgotten &&= forgotten;
@@ -392,8 +487,35 @@ async function waitForAlephInstanceDeletion(options) {
392
487
  if (replicasForgotten && unallocated) return lastSummary;
393
488
  await delay(options.pollIntervalMs ?? 2e3);
394
489
  }
490
+ throw new Error(`Aleph INSTANCE ${options.instanceHash} was not deleted within ${options.timeoutMs ?? 5 * 6e4}ms: ${lastSummary}`);
491
+ }
492
+ async function waitForAlephMessageForgotten(options) {
493
+ const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
494
+ if (!fetchImpl) throw new Error("A fetch implementation is required for cleanup verification");
495
+ const hosts = resolveAlephApiHosts(options.apiHosts);
496
+ const deadline = Date.now() + (options.timeoutMs ?? 2 * 6e4);
497
+ let lastSummary = "Bootstrap deregistration has not been observed yet.";
498
+ while (Date.now() < deadline) {
499
+ let forgottenOnAllReplicas = true;
500
+ const observations = [];
501
+ for (const apiHost of hosts) {
502
+ try {
503
+ const response = await fetchImpl(new URL(`/api/v0/messages/${options.messageHash}`, apiHost), { cache: "no-cache" });
504
+ const payload = await response.json().catch(() => null);
505
+ const forgotten = payload?.status === "forgotten" || Boolean(payload?.forgotten_by?.length);
506
+ forgottenOnAllReplicas &&= forgotten;
507
+ observations.push(`${apiHost}: ${forgotten ? "forgotten" : payload?.status ?? `HTTP ${response.status}`}`);
508
+ } catch (error) {
509
+ forgottenOnAllReplicas = false;
510
+ observations.push(`${apiHost}: ${error instanceof Error ? error.message : String(error)}`);
511
+ }
512
+ }
513
+ lastSummary = observations.join("; ");
514
+ if (forgottenOnAllReplicas) return lastSummary;
515
+ await delay(options.pollIntervalMs ?? 2e3);
516
+ }
395
517
  throw new Error(
396
- `Aleph INSTANCE ${options.instanceHash} was not deleted within ${options.timeoutMs ?? 5 * 6e4}ms: ${lastSummary}`
518
+ `Aleph bootstrap registration ${options.messageHash} was not deregistered within ${options.timeoutMs ?? 2 * 6e4}ms: ${lastSummary}`
397
519
  );
398
520
  }
399
521
  async function cleanupRelay(options) {
@@ -423,13 +545,21 @@ async function cleanupRelay(options) {
423
545
  pollIntervalMs: options.pollIntervalMs,
424
546
  fetch: fetchImpl
425
547
  });
548
+ const registrationVerificationSummary2 = options.registrationHash ? await waitForAlephMessageForgotten({
549
+ messageHash: options.registrationHash,
550
+ apiHosts: hosts,
551
+ timeoutMs: options.timeoutMs,
552
+ pollIntervalMs: options.pollIntervalMs,
553
+ fetch: fetchImpl
554
+ }) : void 0;
426
555
  return {
427
556
  instanceHash: options.instanceHash,
428
557
  uiDeleteRequested,
429
558
  fallbackUsed: false,
430
559
  eraseSummary: "Relay Button UI requested runtime erase",
431
560
  forgetSummary: "Relay Button UI submitted FORGET",
432
- verificationSummary: verificationSummary2
561
+ verificationSummary: verificationSummary2,
562
+ registrationVerificationSummary: registrationVerificationSummary2
433
563
  };
434
564
  } catch {
435
565
  }
@@ -491,13 +621,21 @@ async function cleanupRelay(options) {
491
621
  pollIntervalMs: options.pollIntervalMs,
492
622
  fetch: fetchImpl
493
623
  });
624
+ const registrationVerificationSummary = options.registrationHash ? await waitForAlephMessageForgotten({
625
+ messageHash: options.registrationHash,
626
+ apiHosts: hosts,
627
+ timeoutMs: options.timeoutMs,
628
+ pollIntervalMs: options.pollIntervalMs,
629
+ fetch: fetchImpl
630
+ }) : void 0;
494
631
  return {
495
632
  instanceHash: options.instanceHash,
496
633
  uiDeleteRequested,
497
634
  fallbackUsed: true,
498
635
  eraseSummary,
499
636
  forgetSummary,
500
- verificationSummary
637
+ verificationSummary,
638
+ registrationVerificationSummary
501
639
  };
502
640
  }
503
641
  function createRelayEvidence(options) {
@@ -505,12 +643,7 @@ function createRelayEvidence(options) {
505
643
  instanceName: options.instanceName,
506
644
  ownerAddress: options.ownerAddress,
507
645
  startedAt: new Date(options.startedAt ?? Date.now()).toISOString(),
508
- steps: Object.fromEntries(
509
- Object.entries(options.steps).map(([key, label]) => [
510
- key,
511
- { label, status: "pending" }
512
- ])
513
- )
646
+ steps: Object.fromEntries(Object.entries(options.steps).map(([key, label]) => [key, { label, status: "pending" }]))
514
647
  };
515
648
  }
516
649
  function updateRelayEvidenceStep(evidence, step, status, detail = "") {
@@ -617,6 +750,7 @@ function createRelayTest(options) {
617
750
  account: options.account,
618
751
  instanceName: relay.instanceName,
619
752
  instanceHash: relay.instanceHash,
753
+ registrationHash: relay.registration.itemHash ?? relay.registration.hash ?? void 0,
620
754
  driver: relay.driver
621
755
  })
622
756
  );
@@ -638,20 +772,26 @@ function createRelayTest(options) {
638
772
  }
639
773
  export {
640
774
  DEFAULT_ALEPH_SCHEDULER_URL,
775
+ PLAYWRIGHT_RUNNER_VERSION,
641
776
  RelayButtonDriver,
642
777
  SUPPORTED_ALEPH_API_HOSTS,
643
778
  appendRelayGithubSummary,
779
+ buildAlephCostEvidence,
644
780
  cleanupRelay,
781
+ connectAlephChromium,
645
782
  createRelayEvidence,
646
783
  createRelayTest,
647
784
  findAlephInstanceHash,
785
+ formatAlephCostGithubSummary,
648
786
  formatRelayGithubSummary,
649
787
  installEip1193WalletMock,
650
788
  provisionRelay,
651
789
  resolveAlephApiHosts,
652
790
  selectBrowserRelayAddresses,
791
+ selectExpiredAlephPlaywrightRunners,
653
792
  updateRelayEvidenceStep,
654
793
  waitForAlephInstanceDeletion,
794
+ waitForAlephMessageForgotten,
655
795
  waitForBootstrapRegistration,
656
796
  waitForDeployableManifest,
657
797
  waitForPubsubSubscriber,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/playwright",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "description": "Reusable Playwright fixtures and Relay Button lifecycle helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,8 +24,8 @@
24
24
  "url": "https://github.com/NiKrause/relay-button/issues"
25
25
  },
26
26
  "dependencies": {
27
- "@le-space/aleph-bootstrap": "0.6.28",
28
- "@le-space/core": "0.6.28"
27
+ "@le-space/aleph-bootstrap": "0.6.30",
28
+ "@le-space/core": "0.6.30"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@playwright/test": ">=1.61.1 <1.62.0"