pi-ui-extend 1.0.16 → 1.0.18

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.
@@ -0,0 +1,926 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { createRequire } from "node:module";
7
+ import { pathToFileURL } from "node:url";
8
+ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
9
+ import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
10
+
11
+ const CONFIG_RELATIVE = ".pi/qa_auth.jsonc";
12
+ const STATE_RELATIVE = path.join(".pi", "qa-auth-state");
13
+ const RUNS_RELATIVE = path.join(".pi", "qa-runs");
14
+ const EXIT_AUTH_UPDATE_REQUIRED = 42;
15
+ const EXIT_PROFILE_REQUIRED = 43;
16
+ const PROFILE_ID = /^[A-Za-z0-9._-]+$/;
17
+ const AUTH_TEMPLATE = `{
18
+ // Authenticated browser QA requires at least one project-local profile.
19
+ // Fill and uncomment a profile below. Keep this file private.
20
+ "profiles": {
21
+ // "staging-user": {
22
+ // "description": "Staging user",
23
+ // "traits": ["role:user"],
24
+ // "baseUrl": "https://staging.example.test",
25
+ // "allowedOrigins": ["https://staging.example.test"],
26
+ // "auth": {
27
+ // "type": "form",
28
+ // "loginUrl": "https://staging.example.test/login",
29
+ // "fields": [
30
+ // { "selector": "input[name=email]", "value": "" },
31
+ // { "selector": "input[name=password]", "value": "" }
32
+ // ],
33
+ // "submitSelector": "button[type=submit]",
34
+ // "success": { "selector": "[data-testid=user-menu]" }
35
+ // }
36
+ // }
37
+ }
38
+ }
39
+ `;
40
+ process.umask(0o077);
41
+
42
+ class QaStatusError extends Error {
43
+ constructor(status, reason, exitCode, profileId, details = {}) {
44
+ super(reason);
45
+ this.status = status;
46
+ this.reason = reason;
47
+ this.exitCode = exitCode;
48
+ this.profileId = profileId;
49
+ this.details = details;
50
+ }
51
+ }
52
+
53
+ main().catch((error) => {
54
+ const statusError = error instanceof QaStatusError
55
+ ? error
56
+ : new QaStatusError("QA_RUN_FAILED", safeReason(error), 1);
57
+ writeStatus({
58
+ status: statusError.status,
59
+ profile: statusError.profileId,
60
+ file: CONFIG_RELATIVE,
61
+ reason: statusError.reason,
62
+ ...statusError.details,
63
+ });
64
+ process.exitCode = statusError.exitCode;
65
+ });
66
+
67
+ async function main() {
68
+ const [command = "profiles", ...rawArgs] = process.argv.slice(2);
69
+ const cwd = fs.realpathSync(process.cwd());
70
+ const config = readAuthConfig(cwd);
71
+ if (command === "profiles") {
72
+ writeStatus({ status: "QA_PROFILES", profiles: safeProfiles(config.profiles) });
73
+ return;
74
+ }
75
+ if (command !== "run") throw new QaStatusError("QA_RUN_FAILED", `unknown command: ${command}`, 1);
76
+
77
+ const args = parseArgs(rawArgs);
78
+ const selected = selectProfile(config.profiles, args.profile);
79
+ const profileId = selected.id;
80
+ const profile = selected.profile;
81
+ const secrets = collectSecrets(profile.auth);
82
+ try {
83
+ await runQa({ cwd, args, profileId, profile });
84
+ } catch (error) {
85
+ if (error instanceof QaStatusError) throw error;
86
+ throw new QaStatusError("QA_RUN_FAILED", redact(safeReason(error), secrets), 1, profileId);
87
+ }
88
+ }
89
+
90
+ async function runQa({ cwd, args, profileId, profile }) {
91
+ if (!args.flow) throw new QaStatusError("QA_RUN_FAILED", "--flow is required", 1, profileId);
92
+ const flowPath = resolveExistingPrivateFile(cwd, args.flow, "QA flow", false);
93
+ const flow = readFlow(flowPath, profileId);
94
+ const allowedOrigins = normalizeAllowedOrigins(profile.allowedOrigins, profileId);
95
+ const baseURL = normalizeBaseUrl(args.baseUrl ?? profile.baseUrl ?? allowedOrigins[0], allowedOrigins, profileId);
96
+ validateAuthConfiguration(cwd, profile.auth, allowedOrigins, profileId);
97
+ const runId = safeRunId(args.runId ?? `${timestamp()}-${profileId}`);
98
+ const evidenceDir = path.join(cwd, RUNS_RELATIVE, runId, profileId);
99
+ createExclusivePrivateDirectory(cwd, evidenceDir);
100
+
101
+ const playwright = loadPlaywright(cwd);
102
+ const browser = await playwright.chromium.launch({ headless: true });
103
+ let context;
104
+ let page;
105
+ let video;
106
+ let outcome = "passed";
107
+ let failure;
108
+ const runtimeSecrets = collectSecrets(profile.auth);
109
+ const tracePath = path.join(evidenceDir, "trace.zip");
110
+ try {
111
+ const contextOptions = await contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins, browser });
112
+ context = await browser.newContext({
113
+ ...contextOptions,
114
+ baseURL,
115
+ serviceWorkers: "block",
116
+ recordVideo: { dir: evidenceDir, size: { width: 1280, height: 720 } },
117
+ viewport: { width: 1280, height: 720 },
118
+ });
119
+ await installOriginGuard(context, allowedOrigins, profile.auth);
120
+ await applyContextAuth(context, profile.auth, allowedOrigins, baseURL, profileId);
121
+ runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
122
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
123
+ page = await context.newPage();
124
+ video = page.video();
125
+ await executeFlow({ page, context, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets });
126
+ await assertPageDoesNotExposeSecrets(page, runtimeSecrets, profileId);
127
+ await page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true });
128
+ } catch (error) {
129
+ outcome = error instanceof QaStatusError ? error.status : "failed";
130
+ failure = error;
131
+ if (page && !page.isClosed() && !error.suppressEvidence) {
132
+ try {
133
+ await page.screenshot({ path: path.join(evidenceDir, "failure.png"), fullPage: true });
134
+ } catch { /* best effort */ }
135
+ }
136
+ } finally {
137
+ if (context) {
138
+ try {
139
+ runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
140
+ } catch { /* best effort */ }
141
+ try {
142
+ await context.tracing.stop({ path: tracePath });
143
+ sanitizeTraceArchive(tracePath, runtimeSecrets);
144
+ } catch (error) {
145
+ fs.rmSync(tracePath, { force: true });
146
+ if (!failure) {
147
+ outcome = "failed";
148
+ failure = new Error(`trace evidence could not be sanitized: ${safeReason(error)}`);
149
+ }
150
+ }
151
+ await context.close().catch(() => {});
152
+ }
153
+ if (video) {
154
+ try {
155
+ const generatedVideo = await video.path();
156
+ if (fs.existsSync(generatedVideo)) fs.renameSync(generatedVideo, path.join(evidenceDir, "video.webm"));
157
+ } catch { /* best effort */ }
158
+ }
159
+ await browser.close().catch(() => {});
160
+ }
161
+ if (failure?.suppressEvidence) removeVisualEvidence(evidenceDir);
162
+
163
+ const evidence = existingEvidence(evidenceDir);
164
+ const evidenceDetails = {
165
+ evidenceDir: relativePath(cwd, evidenceDir),
166
+ evidence,
167
+ artifacts: artifactManifest(evidenceDir, evidence),
168
+ };
169
+ writeJsonPrivate(path.join(evidenceDir, "result.json"), {
170
+ status: outcome,
171
+ profile: profileId,
172
+ ...evidenceDetails,
173
+ });
174
+ if (failure) {
175
+ if (failure instanceof QaStatusError) {
176
+ failure.reason = redact(failure.reason, collectSecrets(profile.auth));
177
+ failure.details = { ...failure.details, ...evidenceDetails };
178
+ throw failure;
179
+ }
180
+ throw new QaStatusError(
181
+ "QA_RUN_FAILED",
182
+ redact(safeReason(failure), collectSecrets(profile.auth)),
183
+ 1,
184
+ profileId,
185
+ evidenceDetails,
186
+ );
187
+ }
188
+ writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
189
+ }
190
+
191
+ function readAuthConfig(cwd) {
192
+ const candidate = path.join(cwd, CONFIG_RELATIVE);
193
+ if (!fs.existsSync(candidate)) {
194
+ let templateCreated;
195
+ try {
196
+ templateCreated = createAuthTemplate(cwd);
197
+ } catch (error) {
198
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", safeReason(error), EXIT_AUTH_UPDATE_REQUIRED);
199
+ }
200
+ if (templateCreated) {
201
+ throw new QaStatusError(
202
+ "QA_AUTH_UPDATE_REQUIRED",
203
+ "credentials are required; fill the generated auth config template and rerun browser QA",
204
+ EXIT_AUTH_UPDATE_REQUIRED,
205
+ undefined,
206
+ { action: "provide_credentials", templateCreated: true },
207
+ );
208
+ }
209
+ }
210
+ let file;
211
+ try {
212
+ file = resolveExistingPrivateFile(cwd, CONFIG_RELATIVE, "auth config");
213
+ } catch (error) {
214
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", safeReason(error), EXIT_AUTH_UPDATE_REQUIRED);
215
+ }
216
+ const errors = [];
217
+ const value = parseJsonc(fs.readFileSync(file, "utf8"), errors, { allowTrailingComma: true });
218
+ if (errors.length > 0) {
219
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", `auth config is invalid JSONC (${printParseErrorCode(errors[0].error)})`, EXIT_AUTH_UPDATE_REQUIRED);
220
+ }
221
+ if (!isObject(value) || !isObject(value.profiles) || Object.keys(value.profiles).length === 0) {
222
+ throw new QaStatusError(
223
+ "QA_AUTH_UPDATE_REQUIRED",
224
+ "credentials are required; auth config must define a non-empty profiles object",
225
+ EXIT_AUTH_UPDATE_REQUIRED,
226
+ undefined,
227
+ { action: "provide_credentials", templateCreated: false },
228
+ );
229
+ }
230
+ if (Object.keys(value.profiles).some((id) => !isSafeName(id))) {
231
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth profile ids may contain only letters, digits, dot, underscore, or dash", EXIT_AUTH_UPDATE_REQUIRED);
232
+ }
233
+ return value;
234
+ }
235
+
236
+ function safeProfiles(profiles) {
237
+ return Object.entries(profiles).map(([id, profile]) => ({
238
+ id,
239
+ description: isObject(profile) && typeof profile.description === "string" ? profile.description : undefined,
240
+ traits: isObject(profile) && Array.isArray(profile.traits)
241
+ ? profile.traits.filter((value) => typeof value === "string")
242
+ : [],
243
+ }));
244
+ }
245
+
246
+ function selectProfile(profiles, requestedId) {
247
+ if (!requestedId) {
248
+ throw new QaStatusError(
249
+ "QA_PROFILE_REQUIRED",
250
+ "select one auth profile by id",
251
+ EXIT_PROFILE_REQUIRED,
252
+ undefined,
253
+ { profiles: safeProfiles(profiles) },
254
+ );
255
+ }
256
+ if (!isSafeName(requestedId) || !isObject(profiles[requestedId])) {
257
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "requested auth profile is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
258
+ }
259
+ const profile = profiles[requestedId];
260
+ if (!isObject(profile.auth) || typeof profile.auth.type !== "string") {
261
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile auth configuration is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
262
+ }
263
+ return { id: requestedId, profile };
264
+ }
265
+
266
+ function normalizeAllowedOrigins(value, profileId) {
267
+ if (!Array.isArray(value) || value.length === 0) {
268
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile must define allowedOrigins", EXIT_AUTH_UPDATE_REQUIRED, profileId);
269
+ }
270
+ const origins = [];
271
+ for (const raw of value) {
272
+ try {
273
+ const url = new URL(raw);
274
+ if (!/^https?:$/.test(url.protocol) || url.origin !== raw.replace(/\/$/, "")) throw new Error();
275
+ origins.push(url.origin);
276
+ } catch {
277
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "allowedOrigins must contain exact http(s) origins", EXIT_AUTH_UPDATE_REQUIRED, profileId);
278
+ }
279
+ }
280
+ return [...new Set(origins)];
281
+ }
282
+
283
+ function normalizeBaseUrl(raw, allowedOrigins, profileId) {
284
+ try {
285
+ const url = new URL(raw);
286
+ if (url.username || url.password || !allowedOrigins.includes(url.origin)) throw new Error();
287
+ return url.href;
288
+ } catch {
289
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "base URL is missing or not in allowedOrigins", EXIT_AUTH_UPDATE_REQUIRED, profileId);
290
+ }
291
+ }
292
+
293
+ function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
294
+ switch (auth.type) {
295
+ case "cookie":
296
+ if (!Array.isArray(auth.cookies) || auth.cookies.length === 0) throw authError(profileId, "cookie auth requires cookies");
297
+ for (const cookie of auth.cookies) {
298
+ if (!isObject(cookie) || typeof cookie.name !== "string" || !cookie.name || typeof cookie.value !== "string" || !cookie.value) throw authError(profileId, "cookie auth entries require non-empty name/value strings");
299
+ if (!cookieAllowed(cookie, allowedOrigins)) throw authError(profileId, "cookie auth domain is outside allowedOrigins");
300
+ }
301
+ break;
302
+ case "localStorage":
303
+ case "sessionStorage": {
304
+ const origin = typeof auth.origin === "string" ? auth.origin : allowedOrigins[0];
305
+ if (!isAllowedUrl(origin, allowedOrigins) || !isObject(auth.entries)) throw authError(profileId, `${auth.type} auth is invalid`);
306
+ const entries = Object.entries(auth.entries);
307
+ if (entries.length === 0 || entries.some(([key, value]) => !key || typeof value !== "string" || !value)) throw authError(profileId, `${auth.type} entries must contain non-empty string keys and values`);
308
+ break;
309
+ }
310
+ case "bearer":
311
+ if (typeof auth.token !== "string" || !auth.token) throw authError(profileId, "bearer token is missing");
312
+ if (auth.header !== undefined && (typeof auth.header !== "string" || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(auth.header))) throw authError(profileId, "bearer header name is invalid");
313
+ if (auth.prefix !== undefined && typeof auth.prefix !== "string") throw authError(profileId, "bearer prefix must be a string");
314
+ break;
315
+ case "form":
316
+ if (typeof auth.loginUrl !== "string" || !isAllowedUrl(auth.loginUrl, allowedOrigins)) throw authError(profileId, "form loginUrl is missing or outside allowedOrigins");
317
+ if (!Array.isArray(auth.fields) || auth.fields.length === 0 || auth.fields.some((field) => !isObject(field) || typeof field.selector !== "string" || !field.selector || typeof field.value !== "string" || !field.value)) throw authError(profileId, "form fields require non-empty selector/value strings");
318
+ if (typeof auth.submitSelector !== "string" || !auth.submitSelector) throw authError(profileId, "form submitSelector is missing");
319
+ if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) throw authError(profileId, "form success.url or success.selector is required");
320
+ break;
321
+ case "storageState":
322
+ if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
323
+ try { resolveExistingPrivateFile(cwd, auth.path, "storageState"); } catch (error) { throw authError(profileId, safeReason(error)); }
324
+ break;
325
+ default: throw authError(profileId, `unsupported auth type: ${auth.type}`);
326
+ }
327
+ }
328
+
329
+ function readFlow(flowPath, profileId) {
330
+ const stat = fs.statSync(flowPath);
331
+ if (!stat.isFile() || stat.size > 256 * 1024) throw new QaStatusError("QA_RUN_FAILED", "QA flow must be a regular JSONC file no larger than 256 KiB", 1, profileId);
332
+ const errors = [];
333
+ const flow = parseJsonc(fs.readFileSync(flowPath, "utf8"), errors, { allowTrailingComma: true });
334
+ if (errors.length > 0 || !isObject(flow) || !Array.isArray(flow.steps) || flow.steps.length === 0 || flow.steps.length > 100) {
335
+ throw new QaStatusError("QA_RUN_FAILED", "QA flow must define between 1 and 100 valid steps", 1, profileId);
336
+ }
337
+ return flow;
338
+ }
339
+
340
+ async function executeFlow({ page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets }) {
341
+ const timeout = Math.min(finitePositive(flow.timeoutMs) ?? 15_000, 60_000);
342
+ page.setDefaultTimeout(timeout);
343
+ page.setDefaultNavigationTimeout(timeout);
344
+ for (let index = 0; index < flow.steps.length; index += 1) {
345
+ const step = flow.steps[index];
346
+ if (!isObject(step) || typeof step.action !== "string") throw flowError(profileId, index, "action is missing");
347
+ const locator = step.locator === undefined ? undefined : resolveLocator(page, step.locator, profileId, index);
348
+ switch (step.action) {
349
+ case "goto": {
350
+ const target = typeof step.url === "string" ? step.url : step.path;
351
+ if (typeof target !== "string") throw flowError(profileId, index, "goto requires url or path");
352
+ const url = new URL(target, baseURL);
353
+ if (url.username || url.password || !allowedOrigins.includes(url.origin)) throw flowError(profileId, index, "goto target is outside allowedOrigins");
354
+ await page.goto(url.href, { waitUntil: validWaitUntil(step.waitUntil) });
355
+ break;
356
+ }
357
+ case "reload": await page.reload({ waitUntil: validWaitUntil(step.waitUntil) }); break;
358
+ case "click": await requireLocator(locator, profileId, index).click(); break;
359
+ case "doubleClick": await requireLocator(locator, profileId, index).dblclick(); break;
360
+ case "hover": await requireLocator(locator, profileId, index).hover(); break;
361
+ case "fill":
362
+ if (typeof step.value !== "string") throw flowError(profileId, index, "fill requires a string value");
363
+ await requireLocator(locator, profileId, index).fill(step.value);
364
+ break;
365
+ case "press":
366
+ if (typeof step.key !== "string" || step.key.length > 80) throw flowError(profileId, index, "press requires a valid key");
367
+ await requireLocator(locator, profileId, index).press(step.key);
368
+ break;
369
+ case "check": await requireLocator(locator, profileId, index).check(); break;
370
+ case "uncheck": await requireLocator(locator, profileId, index).uncheck(); break;
371
+ case "selectOption":
372
+ if (typeof step.value !== "string" && !isStringArray(step.value)) throw flowError(profileId, index, "selectOption requires a string or string array");
373
+ await requireLocator(locator, profileId, index).selectOption(step.value);
374
+ break;
375
+ case "waitFor": await requireLocator(locator, profileId, index).waitFor({ state: validLocatorState(step.state) }); break;
376
+ case "waitForTimeout":
377
+ if (!finitePositive(step.timeoutMs) || step.timeoutMs > 5_000) throw flowError(profileId, index, "waitForTimeout must be between 1 and 5000 ms");
378
+ await page.waitForTimeout(step.timeoutMs);
379
+ break;
380
+ case "assertVisible": assertCondition(await requireLocator(locator, profileId, index).isVisible(), profileId, index, "expected locator to be visible"); break;
381
+ case "assertHidden": assertCondition(await requireLocator(locator, profileId, index).isHidden(), profileId, index, "expected locator to be hidden"); break;
382
+ case "assertEnabled": assertCondition(await requireLocator(locator, profileId, index).isEnabled(), profileId, index, "expected locator to be enabled"); break;
383
+ case "assertDisabled": assertCondition(await requireLocator(locator, profileId, index).isDisabled(), profileId, index, "expected locator to be disabled"); break;
384
+ case "assertChecked": assertCondition(await requireLocator(locator, profileId, index).isChecked(), profileId, index, "expected locator to be checked"); break;
385
+ case "assertUnchecked": assertCondition(!(await requireLocator(locator, profileId, index).isChecked()), profileId, index, "expected locator to be unchecked"); break;
386
+ case "assertText": {
387
+ const actual = (await requireLocator(locator, profileId, index).textContent()) ?? "";
388
+ assertExpectedString(actual, step, profileId, index, "text");
389
+ break;
390
+ }
391
+ case "assertValue": {
392
+ const actual = await requireLocator(locator, profileId, index).inputValue();
393
+ assertExpectedString(actual, step, profileId, index, "value");
394
+ break;
395
+ }
396
+ case "assertCount":
397
+ if (!Number.isInteger(step.equals) || step.equals < 0) throw flowError(profileId, index, "assertCount requires a non-negative integer equals");
398
+ assertCondition((await requireLocator(locator, profileId, index).count()) === step.equals, profileId, index, "locator count assertion failed");
399
+ break;
400
+ case "assertURL": assertExpectedString(page.url(), step, profileId, index, "URL"); break;
401
+ case "authRejectedIf": {
402
+ const urlRejected = typeof step.urlIncludes === "string" && page.url().includes(step.urlIncludes);
403
+ const locatorRejected = locator ? await locator.isVisible().catch(() => false) : false;
404
+ if (!urlRejected && !locatorRejected) break;
405
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "application rejected or expired the configured authentication", EXIT_AUTH_UPDATE_REQUIRED, profileId);
406
+ }
407
+ case "screenshot": {
408
+ const name = typeof step.name === "string" ? step.name : `step-${index + 1}`;
409
+ if (!isSafeName(name)) throw flowError(profileId, index, "screenshot name is invalid");
410
+ await assertPageDoesNotExposeSecrets(page, secrets, profileId);
411
+ await page.screenshot({ path: path.join(evidenceDir, `${name}.png`), fullPage: step.fullPage !== false });
412
+ break;
413
+ }
414
+ default: throw flowError(profileId, index, `unsupported action: ${step.action}`);
415
+ }
416
+ await assertPageDoesNotExposeSecrets(page, secrets, profileId);
417
+ }
418
+ }
419
+
420
+ function resolveLocator(page, value, profileId, index) {
421
+ if (!isObject(value)) throw flowError(profileId, index, "locator must be an object");
422
+ const exact = value.exact === true;
423
+ if (typeof value.testId === "string") return page.getByTestId(value.testId);
424
+ if (typeof value.role === "string") return page.getByRole(value.role, typeof value.name === "string" ? { name: value.name, exact } : undefined);
425
+ if (typeof value.label === "string") return page.getByLabel(value.label, { exact });
426
+ if (typeof value.placeholder === "string") return page.getByPlaceholder(value.placeholder, { exact });
427
+ if (typeof value.text === "string") return page.getByText(value.text, { exact });
428
+ if (typeof value.css === "string") return page.locator(value.css);
429
+ throw flowError(profileId, index, "locator requires testId, role, label, placeholder, text, or css");
430
+ }
431
+
432
+ function requireLocator(locator, profileId, index) {
433
+ if (!locator) throw flowError(profileId, index, "action requires locator");
434
+ return locator;
435
+ }
436
+
437
+ function assertExpectedString(actual, step, profileId, index, label) {
438
+ const hasEquals = typeof step.equals === "string";
439
+ const hasIncludes = typeof step.includes === "string";
440
+ if (hasEquals === hasIncludes) throw flowError(profileId, index, `${label} assertion requires exactly one of equals or includes`);
441
+ assertCondition(hasEquals ? actual === step.equals : actual.includes(step.includes), profileId, index, `${label} assertion failed`);
442
+ }
443
+
444
+ function assertCondition(condition, profileId, index, reason) {
445
+ if (!condition) throw flowError(profileId, index, reason);
446
+ }
447
+
448
+ function flowError(profileId, index, reason) {
449
+ return new QaStatusError("QA_RUN_FAILED", `flow step ${index + 1}: ${reason}`, 1, profileId);
450
+ }
451
+
452
+ function validWaitUntil(value) {
453
+ return ["commit", "domcontentloaded", "load", "networkidle"].includes(value) ? value : "load";
454
+ }
455
+
456
+ function validLocatorState(value) {
457
+ return ["attached", "detached", "visible", "hidden"].includes(value) ? value : "visible";
458
+ }
459
+
460
+ function isStringArray(value) {
461
+ return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string");
462
+ }
463
+
464
+ async function contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins, browser }) {
465
+ const auth = profile.auth;
466
+ if (auth.type === "storageState") {
467
+ if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
468
+ const statePath = resolveExistingPrivateFile(cwd, auth.path, "storageState");
469
+ return { storageState: filteredStorageState(statePath, allowedOrigins, profileId) };
470
+ }
471
+ if (auth.type === "form") {
472
+ const stateDirectory = path.join(cwd, STATE_RELATIVE);
473
+ const statePath = path.join(stateDirectory, `${profileId}-${authCacheKey(auth, allowedOrigins)}.json`);
474
+ if (!fs.existsSync(statePath)) {
475
+ ensurePrivateDirectory(cwd, stateDirectory);
476
+ for (const name of fs.readdirSync(stateDirectory)) {
477
+ if (name.startsWith(`${profileId}-`) && name.endsWith(".json")) fs.rmSync(path.join(stateDirectory, name), { force: true });
478
+ }
479
+ await createFormState({ statePath, auth, allowedOrigins, browser, profileId });
480
+ }
481
+ return { storageState: filteredStorageState(statePath, allowedOrigins, profileId) };
482
+ }
483
+ if (!["cookie", "localStorage", "sessionStorage", "bearer"].includes(auth.type)) {
484
+ throw authError(profileId, `unsupported auth type: ${auth.type}`);
485
+ }
486
+ return {};
487
+ }
488
+
489
+ async function createFormState({ statePath, auth, allowedOrigins, browser, profileId }) {
490
+ if (typeof auth.loginUrl !== "string" || !isAllowedUrl(auth.loginUrl, allowedOrigins)) {
491
+ throw authError(profileId, "form loginUrl is missing or outside allowedOrigins");
492
+ }
493
+ if (!Array.isArray(auth.fields) || auth.fields.length === 0 || typeof auth.submitSelector !== "string") {
494
+ throw authError(profileId, "form fields or submitSelector are missing");
495
+ }
496
+ const context = await browser.newContext();
497
+ try {
498
+ await installOriginGuard(context, allowedOrigins, auth);
499
+ const page = await context.newPage();
500
+ await page.goto(auth.loginUrl);
501
+ for (const field of auth.fields) {
502
+ if (!isObject(field) || typeof field.selector !== "string" || typeof field.value !== "string") {
503
+ throw authError(profileId, "form fields must contain selector/value strings");
504
+ }
505
+ await page.locator(field.selector).fill(field.value);
506
+ }
507
+ await page.locator(auth.submitSelector).click();
508
+ const timeout = finitePositive(auth.timeoutMs) ?? 15_000;
509
+ if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
510
+ if (isObject(auth.success) && typeof auth.success.selector === "string") await page.locator(auth.success.selector).waitFor({ timeout });
511
+ if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
512
+ throw authError(profileId, "form success.url or success.selector is required");
513
+ }
514
+ fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
515
+ await context.storageState({ path: statePath });
516
+ fs.chmodSync(statePath, 0o600);
517
+ } catch (error) {
518
+ if (error instanceof QaStatusError) throw error;
519
+ throw authError(profileId, "form login was rejected or did not reach the configured success condition");
520
+ } finally {
521
+ await context.close().catch(() => {});
522
+ }
523
+ }
524
+
525
+ async function applyContextAuth(context, auth, allowedOrigins, baseURL, profileId) {
526
+ if (auth.type === "cookie") {
527
+ if (!Array.isArray(auth.cookies) || auth.cookies.length === 0) throw authError(profileId, "cookie auth requires cookies");
528
+ for (const cookie of auth.cookies) {
529
+ if (!isObject(cookie) || typeof cookie.name !== "string" || typeof cookie.value !== "string") throw authError(profileId, "invalid cookie auth entry");
530
+ if (!cookieAllowed(cookie, allowedOrigins)) throw authError(profileId, "cookie auth domain is outside allowedOrigins");
531
+ }
532
+ await context.addCookies(auth.cookies);
533
+ }
534
+ if (auth.type === "localStorage" || auth.type === "sessionStorage") {
535
+ const originUrl = typeof auth.origin === "string" ? auth.origin : baseURL;
536
+ if (!isAllowedUrl(originUrl, allowedOrigins) || !isObject(auth.entries)) throw authError(profileId, `${auth.type} auth is invalid`);
537
+ const origin = new URL(originUrl).origin;
538
+ const storage = auth.type;
539
+ const entries = Object.entries(auth.entries);
540
+ if (entries.length === 0 || entries.some(([, value]) => typeof value !== "string")) throw authError(profileId, `${auth.type} entries must be non-empty strings`);
541
+ await context.addInitScript(({ expectedOrigin, storageName, values }) => {
542
+ if (location.origin !== expectedOrigin) return;
543
+ const target = storageName === "localStorage" ? localStorage : sessionStorage;
544
+ for (const [key, value] of values) target.setItem(key, value);
545
+ }, { expectedOrigin: origin, storageName: storage, values: entries });
546
+ }
547
+ }
548
+
549
+ function authCacheKey(auth, allowedOrigins) {
550
+ return createHash("sha256").update(JSON.stringify({ auth, allowedOrigins })).digest("hex").slice(0, 16);
551
+ }
552
+
553
+ async function installOriginGuard(context, allowedOrigins, auth) {
554
+ await context.route("**/*", async (route) => {
555
+ const request = route.request();
556
+ let url;
557
+ try { url = new URL(request.url()); } catch {
558
+ await route.abort("blockedbyclient");
559
+ return;
560
+ }
561
+ if (!isAllowedNetworkUrl(url, allowedOrigins)) {
562
+ await route.abort("blockedbyclient");
563
+ return;
564
+ }
565
+ if (auth.type === "bearer") {
566
+ const headerName = typeof auth.header === "string" && auth.header ? auth.header : "Authorization";
567
+ const prefix = typeof auth.prefix === "string" ? auth.prefix : "Bearer ";
568
+ const headers = Object.fromEntries(Object.entries(request.headers()).filter(([name]) => name.toLowerCase() !== headerName.toLowerCase()));
569
+ await route.continue({ headers: { ...headers, [headerName]: `${prefix}${auth.token}` } });
570
+ return;
571
+ }
572
+ await route.continue();
573
+ });
574
+ if (typeof context.routeWebSocket !== "function") throw new Error("installed Playwright does not support fail-closed WebSocket routing");
575
+ await context.routeWebSocket("**/*", async (webSocket) => {
576
+ let allowed = false;
577
+ try { allowed = isAllowedNetworkUrl(new URL(webSocket.url()), allowedOrigins); } catch { /* blocked */ }
578
+ if (!allowed) {
579
+ await webSocket.close({ code: 1008, reason: "origin blocked by browser QA" });
580
+ return;
581
+ }
582
+ webSocket.connectToServer();
583
+ });
584
+ }
585
+
586
+ function isAllowedNetworkUrl(url, allowedOrigins) {
587
+ if (allowedOrigins.includes(url.origin)) return true;
588
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") return false;
589
+ const httpProtocol = url.protocol === "wss:" ? "https:" : "http:";
590
+ return allowedOrigins.includes(`${httpProtocol}//${url.host}`);
591
+ }
592
+
593
+ function filteredStorageState(statePath, allowedOrigins, profileId) {
594
+ let state;
595
+ try {
596
+ state = JSON.parse(fs.readFileSync(statePath, "utf8"));
597
+ } catch {
598
+ throw authError(profileId, "storageState is unreadable or invalid");
599
+ }
600
+ return {
601
+ cookies: Array.isArray(state.cookies) ? state.cookies.filter((cookie) => cookieAllowed(cookie, allowedOrigins)) : [],
602
+ origins: Array.isArray(state.origins) ? state.origins.filter((entry) => isObject(entry) && allowedOrigins.includes(entry.origin)) : [],
603
+ };
604
+ }
605
+
606
+ function cookieAllowed(cookie, allowedOrigins) {
607
+ if (!isObject(cookie)) return false;
608
+ if (typeof cookie.url === "string") return isAllowedUrl(cookie.url, allowedOrigins);
609
+ if (typeof cookie.domain !== "string") return false;
610
+ const domain = cookie.domain.replace(/^\./, "").toLowerCase();
611
+ return allowedOrigins.some((origin) => new URL(origin).hostname.toLowerCase() === domain);
612
+ }
613
+
614
+ function loadPlaywright(cwd) {
615
+ const requireCandidates = [createRequire(path.join(cwd, "package.json")), createRequire(import.meta.url)];
616
+ const cliPath = findExecutable("playwright-cli");
617
+ if (cliPath) requireCandidates.push(createRequire(findPackageJson(fs.realpathSync(cliPath))));
618
+ for (const require of requireCandidates) {
619
+ for (const name of ["playwright", "@playwright/test"]) {
620
+ try {
621
+ const module = require(name);
622
+ if (module.chromium) return module;
623
+ } catch { /* try next resolution root */ }
624
+ }
625
+ }
626
+ throw new Error("Playwright is unavailable; install project playwright or @playwright/cli");
627
+ }
628
+
629
+ function findExecutable(name) {
630
+ const names = process.platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
631
+ for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
632
+ for (const candidate of names) {
633
+ const file = path.join(directory, candidate);
634
+ if (fs.existsSync(file)) return file;
635
+ }
636
+ }
637
+ }
638
+
639
+ function findPackageJson(startFile) {
640
+ let directory = path.dirname(startFile);
641
+ while (directory !== path.dirname(directory)) {
642
+ const candidate = path.join(directory, "package.json");
643
+ if (fs.existsSync(candidate)) return candidate;
644
+ directory = path.dirname(directory);
645
+ }
646
+ return new URL(import.meta.url);
647
+ }
648
+
649
+ function parseArgs(values) {
650
+ const result = {};
651
+ for (let index = 0; index < values.length; index += 2) {
652
+ const flag = values[index];
653
+ const value = values[index + 1];
654
+ if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
655
+ const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
656
+ if (!["profile", "flow", "baseUrl", "runId"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
657
+ result[key] = value;
658
+ }
659
+ return result;
660
+ }
661
+
662
+ function collectSecrets(value, output = []) {
663
+ if (!isObject(value)) return output;
664
+ if (typeof value.token === "string" && value.token.length > 0) output.push(value.token);
665
+ if (Array.isArray(value.fields)) {
666
+ for (const field of value.fields) {
667
+ if (isObject(field) && typeof field.value === "string" && field.value.length > 0) output.push(field.value);
668
+ }
669
+ }
670
+ if (Array.isArray(value.cookies)) {
671
+ for (const cookie of value.cookies) {
672
+ if (isObject(cookie) && typeof cookie.value === "string" && cookie.value.length > 0) output.push(cookie.value);
673
+ }
674
+ }
675
+ if (isObject(value.entries)) {
676
+ for (const entry of Object.values(value.entries)) {
677
+ if (typeof entry === "string" && entry.length > 0) output.push(entry);
678
+ }
679
+ }
680
+ return expandSecretForms(output);
681
+ }
682
+
683
+ function collectStorageStateSecrets(state) {
684
+ const output = [];
685
+ if (isObject(state) && Array.isArray(state.cookies)) {
686
+ for (const cookie of state.cookies) {
687
+ if (isObject(cookie) && typeof cookie.value === "string" && cookie.value.length > 0) output.push(cookie.value);
688
+ }
689
+ }
690
+ if (isObject(state) && Array.isArray(state.origins)) {
691
+ for (const origin of state.origins) {
692
+ if (!isObject(origin) || !Array.isArray(origin.localStorage)) continue;
693
+ for (const entry of origin.localStorage) {
694
+ if (isObject(entry) && typeof entry.value === "string" && entry.value.length > 0) output.push(entry.value);
695
+ }
696
+ }
697
+ }
698
+ return expandSecretForms(output);
699
+ }
700
+
701
+ function expandSecretForms(values) {
702
+ const forms = new Set();
703
+ for (const value of values) {
704
+ if (typeof value !== "string" || value.length === 0) continue;
705
+ forms.add(value);
706
+ forms.add(encodeURIComponent(value));
707
+ forms.add(Buffer.from(value, "utf8").toString("base64"));
708
+ forms.add(Buffer.from(value, "utf8").toString("base64url"));
709
+ }
710
+ return [...forms].filter(Boolean).sort((left, right) => right.length - left.length);
711
+ }
712
+
713
+ async function assertPageDoesNotExposeSecrets(page, secrets, profileId) {
714
+ if (!page || page.isClosed() || secrets.length === 0) return;
715
+ const haystacks = [page.url(), await page.content()];
716
+ if (!secrets.some((secret) => secret && haystacks.some((value) => value.includes(secret)))) return;
717
+ const error = new QaStatusError("QA_RUN_FAILED", "configured authentication appeared in rendered page content; visual evidence was discarded", 1, profileId);
718
+ error.suppressEvidence = true;
719
+ throw error;
720
+ }
721
+
722
+ function sanitizeTraceArchive(tracePath, secrets) {
723
+ const archive = unzipSync(new Uint8Array(fs.readFileSync(tracePath)));
724
+ const sanitized = {};
725
+ for (const [name, bytes] of Object.entries(archive)) {
726
+ if (name.endsWith(".network")) continue;
727
+ if (name.startsWith("resources/") && !isImage(bytes)) continue;
728
+ if (isImage(bytes)) {
729
+ sanitized[name] = bytes;
730
+ continue;
731
+ }
732
+ let text = strFromU8(bytes);
733
+ for (const secret of [...new Set(secrets)].filter(Boolean)) {
734
+ text = text.split(secret).join("[REDACTED]");
735
+ }
736
+ sanitized[name] = strToU8(text);
737
+ }
738
+ if (Object.keys(sanitized).length === 0) throw new Error("trace archive contained no safe entries");
739
+ for (const bytes of Object.values(sanitized)) {
740
+ if (isImage(bytes)) continue;
741
+ const text = strFromU8(bytes);
742
+ for (const secret of secrets) {
743
+ if (secret && text.includes(secret)) throw new Error("trace redaction verification failed");
744
+ }
745
+ }
746
+ fs.writeFileSync(tracePath, zipSync(sanitized, { level: 6 }), { mode: 0o600 });
747
+ fs.chmodSync(tracePath, 0o600);
748
+ }
749
+
750
+ function isImage(bytes) {
751
+ return (
752
+ (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
753
+ || (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
754
+ || (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38)
755
+ || (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50)
756
+ );
757
+ }
758
+
759
+ function redact(value, secrets) {
760
+ let output = String(value).replace(/[\r\n]+/g, " ").slice(0, 500);
761
+ for (const secret of secrets) if (secret) output = output.split(secret).join("[REDACTED]");
762
+ return output;
763
+ }
764
+
765
+ function safeReason(error) {
766
+ return error instanceof Error ? error.message : String(error);
767
+ }
768
+
769
+ function authError(profileId, reason) {
770
+ return new QaStatusError("QA_AUTH_UPDATE_REQUIRED", reason, EXIT_AUTH_UPDATE_REQUIRED, profileId);
771
+ }
772
+
773
+ function isAllowedUrl(raw, allowedOrigins) {
774
+ try {
775
+ const url = new URL(raw);
776
+ return !url.username && !url.password && allowedOrigins.includes(url.origin);
777
+ } catch {
778
+ return false;
779
+ }
780
+ }
781
+
782
+ function resolveExistingPrivateFile(cwd, value, label, requirePrivate = true) {
783
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${label} path is missing`);
784
+ const root = fs.realpathSync(cwd);
785
+ const resolved = path.resolve(root, value);
786
+ assertInside(root, resolved, label);
787
+ if (!fs.existsSync(resolved)) throw new Error(`${label} is missing`);
788
+ assertNoSymlinkComponents(root, resolved, label);
789
+ const real = fs.realpathSync(resolved);
790
+ assertInside(root, real, label);
791
+ const stat = fs.statSync(real);
792
+ if (!stat.isFile()) throw new Error(`${label} must be a regular file`);
793
+ if (requirePrivate && process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error(`${label} must use private file permissions (0600)`);
794
+ return real;
795
+ }
796
+
797
+ function assertInside(root, target, label) {
798
+ const relative = path.relative(root, target);
799
+ if (!relative || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
800
+ throw new Error(`${label} must be project-local`);
801
+ }
802
+
803
+ function assertNoSymlinkComponents(root, target, label) {
804
+ let current = root;
805
+ for (const part of path.relative(root, target).split(path.sep)) {
806
+ current = path.join(current, part);
807
+ if (fs.lstatSync(current).isSymbolicLink()) throw new Error(`${label} must not use symbolic links`);
808
+ }
809
+
810
+ }
811
+
812
+ function createAuthTemplate(cwd) {
813
+ const root = fs.realpathSync(cwd);
814
+ const directory = path.join(root, path.dirname(CONFIG_RELATIVE));
815
+ const file = path.join(root, CONFIG_RELATIVE);
816
+ assertInside(root, directory, "auth config directory");
817
+ if (!fs.existsSync(directory)) {
818
+ try {
819
+ fs.mkdirSync(directory, { mode: 0o700 });
820
+ } catch (error) {
821
+ if (!isAlreadyExistsError(error)) throw error;
822
+ }
823
+ }
824
+ const directoryStat = fs.lstatSync(directory);
825
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
826
+ throw new Error("auth config directory must be a real project-local directory");
827
+ }
828
+ try {
829
+ fs.writeFileSync(file, AUTH_TEMPLATE, { encoding: "utf8", mode: 0o600, flag: "wx" });
830
+ } catch (error) {
831
+ if (isAlreadyExistsError(error)) return false;
832
+ throw error;
833
+ }
834
+ fs.chmodSync(file, 0o600);
835
+ return true;
836
+ }
837
+
838
+ function isAlreadyExistsError(error) {
839
+ return error !== null && typeof error === "object" && error.code === "EEXIST";
840
+ }
841
+
842
+ function createExclusivePrivateDirectory(cwd, target) {
843
+ createPrivateDirectory(cwd, target, true);
844
+ }
845
+
846
+ function ensurePrivateDirectory(cwd, target) {
847
+ createPrivateDirectory(cwd, target, false);
848
+ }
849
+
850
+ function createPrivateDirectory(cwd, target, exclusive) {
851
+ const root = fs.realpathSync(cwd);
852
+ const resolved = path.resolve(target);
853
+ assertInside(root, resolved, "evidence directory");
854
+ const parts = path.relative(root, resolved).split(path.sep);
855
+ let current = root;
856
+ for (let index = 0; index < parts.length; index += 1) {
857
+ current = path.join(current, parts[index]);
858
+ const isFinal = index === parts.length - 1;
859
+ if (fs.existsSync(current)) {
860
+ const stat = fs.lstatSync(current);
861
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("evidence path must contain only real directories");
862
+ if (isFinal && exclusive) throw new Error("evidence directory already exists");
863
+ if (index > 0 && process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("QA private directories must use mode 0700");
864
+ } else {
865
+ fs.mkdirSync(current, { mode: 0o700 });
866
+ }
867
+ }
868
+ }
869
+
870
+ function safeRunId(value) {
871
+ if (!isSafeName(value)) throw new Error("run id must contain only letters, digits, dot, underscore, or dash without dot segments");
872
+ return value;
873
+ }
874
+
875
+ function isSafeName(value) {
876
+ return typeof value === "string" && PROFILE_ID.test(value) && value !== "." && !value.includes("..");
877
+ }
878
+
879
+ function timestamp() {
880
+ return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
881
+ }
882
+
883
+ function relativePath(cwd, value) {
884
+ return (path.relative(cwd, value) || ".").split(path.sep).join("/");
885
+ }
886
+
887
+ function existingEvidence(evidenceDir) {
888
+ return fs.readdirSync(evidenceDir)
889
+ .filter((name) => /^(?:[A-Za-z0-9._-]+\.png|video\.webm|trace\.zip)$/.test(name))
890
+ .sort();
891
+ }
892
+
893
+ function artifactManifest(evidenceDir, evidence) {
894
+ const artifacts = { screenshots: [], videos: [], traces: [] };
895
+ for (const name of evidence) {
896
+ const absolutePath = path.resolve(evidenceDir, name);
897
+ const artifact = { path: absolutePath, uri: pathToFileURL(absolutePath).href };
898
+ if (name.endsWith(".png")) artifacts.screenshots.push(artifact);
899
+ else if (name.endsWith(".webm")) artifacts.videos.push(artifact);
900
+ else if (name.endsWith(".zip")) artifacts.traces.push(artifact);
901
+ }
902
+ return artifacts;
903
+ }
904
+
905
+ function removeVisualEvidence(evidenceDir) {
906
+ for (const name of fs.readdirSync(evidenceDir)) {
907
+ if (/\.(?:png|webm|zip)$/.test(name)) fs.rmSync(path.join(evidenceDir, name), { force: true });
908
+ }
909
+ }
910
+
911
+ function writeJsonPrivate(file, value) {
912
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
913
+ fs.chmodSync(file, 0o600);
914
+ }
915
+
916
+ function writeStatus(value) {
917
+ process.stdout.write(`${JSON.stringify(value)}\n`);
918
+ }
919
+
920
+ function finitePositive(value) {
921
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
922
+ }
923
+
924
+ function isObject(value) {
925
+ return value !== null && typeof value === "object" && !Array.isArray(value);
926
+ }