@saywright/studio 0.1.1 → 0.2.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/dist/THIRD_PARTY_NOTICES.md +0 -6
- package/dist/bridge.js +1109 -179
- package/dist/cli.mjs +1966 -375
- package/dist/client/assets/CommandPalette-VEQNxJ4K.js +1 -0
- package/dist/client/assets/CommandPalette-VEQNxJ4K.js.br +0 -0
- package/dist/client/assets/CommandPalette-VEQNxJ4K.js.gz +0 -0
- package/dist/client/assets/HelpDialog-CnCVSSNP.js +1 -0
- package/dist/client/assets/HelpDialog-CnCVSSNP.js.br +0 -0
- package/dist/client/assets/HelpDialog-CnCVSSNP.js.gz +0 -0
- package/dist/client/assets/ReportView-BwBy4JIG.js +4 -0
- package/dist/client/assets/ReportView-BwBy4JIG.js.br +0 -0
- package/dist/client/assets/ReportView-BwBy4JIG.js.gz +0 -0
- package/dist/client/assets/SettingsDialog-CS5w9CxJ.js +1 -0
- package/dist/client/assets/SettingsDialog-CS5w9CxJ.js.br +0 -0
- package/dist/client/assets/SettingsDialog-CS5w9CxJ.js.gz +0 -0
- package/dist/client/assets/arrow-left-BpsxETMA.js +1 -0
- package/dist/client/assets/brand-BSL9G2NW.js +57 -0
- package/dist/client/assets/brand-BSL9G2NW.js.br +0 -0
- package/dist/client/assets/brand-BSL9G2NW.js.gz +0 -0
- package/dist/client/assets/footprints-Bc2dzReV.js +1 -0
- package/dist/client/assets/index-BrM0s_jp.css +2 -0
- package/dist/client/assets/index-BrM0s_jp.css.br +0 -0
- package/dist/client/assets/index-BrM0s_jp.css.gz +0 -0
- package/dist/client/assets/index-C3xqQUPf.js +19 -0
- package/dist/client/assets/index-C3xqQUPf.js.br +0 -0
- package/dist/client/assets/index-C3xqQUPf.js.gz +0 -0
- package/dist/client/assets/refresh-cw-DocKnJID.js +1 -0
- package/dist/client/index.html +3 -5
- package/dist/client/index.html.br +0 -0
- package/dist/client/index.html.gz +0 -0
- package/dist/sample/package.json +1 -1
- package/dist/sample/src/App.tsx +3 -2
- package/dist/sample/src/lib/route.ts +3 -2
- package/dist/sample/src/pages/Account.tsx +88 -0
- package/dist/sample/src/styles.css +5 -0
- package/package.json +1 -2
- package/dist/client/assets/Calibration-xY4YDLzU.js +0 -1
- package/dist/client/assets/Calibration-xY4YDLzU.js.br +0 -0
- package/dist/client/assets/Calibration-xY4YDLzU.js.gz +0 -0
- package/dist/client/assets/CommandPalette-BA3akF-p.js +0 -1
- package/dist/client/assets/CommandPalette-BA3akF-p.js.br +0 -0
- package/dist/client/assets/CommandPalette-BA3akF-p.js.gz +0 -0
- package/dist/client/assets/HelpDialog-C6qmdEwi.js +0 -1
- package/dist/client/assets/HelpDialog-C6qmdEwi.js.br +0 -0
- package/dist/client/assets/HelpDialog-C6qmdEwi.js.gz +0 -0
- package/dist/client/assets/ReportView-Bj9xxLCi.js +0 -4
- package/dist/client/assets/ReportView-Bj9xxLCi.js.br +0 -0
- package/dist/client/assets/ReportView-Bj9xxLCi.js.gz +0 -0
- package/dist/client/assets/SettingsDialog-VrACcdUX.js +0 -1
- package/dist/client/assets/SettingsDialog-VrACcdUX.js.br +0 -0
- package/dist/client/assets/SettingsDialog-VrACcdUX.js.gz +0 -0
- package/dist/client/assets/button-B2NFEpCs.js +0 -18
- package/dist/client/assets/button-B2NFEpCs.js.br +0 -0
- package/dist/client/assets/button-B2NFEpCs.js.gz +0 -0
- package/dist/client/assets/camera-BD9K1f5I.js +0 -1
- package/dist/client/assets/confirm-D1U-bFIL.js +0 -41
- package/dist/client/assets/confirm-D1U-bFIL.js.br +0 -0
- package/dist/client/assets/confirm-D1U-bFIL.js.gz +0 -0
- package/dist/client/assets/footprints-DiYtIryA.js +0 -1
- package/dist/client/assets/index-BakNwOFF.css +0 -2
- package/dist/client/assets/index-BakNwOFF.css.br +0 -0
- package/dist/client/assets/index-BakNwOFF.css.gz +0 -0
- package/dist/client/assets/index-DUAtV_Av.js +0 -18
- package/dist/client/assets/index-DUAtV_Av.js.br +0 -0
- package/dist/client/assets/index-DUAtV_Av.js.gz +0 -0
- package/dist/client/assets/preload-helper-BLdHbfdN.js +0 -1
- package/dist/client/assets/preload-helper-BLdHbfdN.js.br +0 -0
- package/dist/client/assets/preload-helper-BLdHbfdN.js.gz +0 -0
- package/dist/client/assets/refresh-cw-Dobx2YmO.js +0 -1
- package/dist/client/assets/tracker.worker-DWwj76VA.js +0 -41
- package/dist/client/assets/tracker.worker-DWwj76VA.js.br +0 -0
- package/dist/client/assets/tracker.worker-DWwj76VA.js.gz +0 -0
- package/dist/client/assets/vision_bundle-ByYCzljp.js +0 -41
- package/dist/client/assets/vision_bundle-ByYCzljp.js.br +0 -0
- package/dist/client/assets/vision_bundle-ByYCzljp.js.gz +0 -0
package/dist/cli.mjs
CHANGED
|
@@ -274,8 +274,323 @@ function speechDir(layout) {
|
|
|
274
274
|
}
|
|
275
275
|
//#endregion
|
|
276
276
|
//#region ../../packages/vite/dist/protocol.js
|
|
277
|
+
/**
|
|
278
|
+
* What is never recorded: passwords, one-time codes, keys and tokens, card and bank numbers. The bridge's two
|
|
279
|
+
* recorders (test sessions, and Write test), the studio (for what an older plugin still sends), the tests it writes
|
|
280
|
+
* and what it sends an AI all go by these rules, so a secret is caught the same way wherever it would show.
|
|
281
|
+
*
|
|
282
|
+
* A field is secret by what it is (a password field, a field someone marked private) or by what it is called (its
|
|
283
|
+
* name, id, autocomplete, label or placeholder: “Password”, “cc-number”, “API key”, “رمز التحقق”). A value is secret
|
|
284
|
+
* by its look alone (a card number, a key or a token), whatever its field is called. A test types a secret from the
|
|
285
|
+
* environment (SAYWRIGHT_SECRET_PASSWORD), never from its own file.
|
|
286
|
+
*/
|
|
287
|
+
/** Shown in place of a secret. */
|
|
288
|
+
var MASK = "•••";
|
|
289
|
+
/** A generated test reads the secret named PASSWORD from the environment variable SAYWRIGHT_SECRET_PASSWORD. */
|
|
290
|
+
var SECRET_ENV_PREFIX = "SAYWRIGHT_SECRET_";
|
|
291
|
+
/** A value as it may be shown: a dot for each character, at most twelve, so its length is a hint and nothing more. */
|
|
292
|
+
function dots(value) {
|
|
293
|
+
return "•".repeat(Math.min(value.length, 12));
|
|
294
|
+
}
|
|
295
|
+
/** A name, id or label as plain words: “cardNumber”, “card_number”, “cc-number” → “card number”, “cc number”. */
|
|
296
|
+
function words$1(text) {
|
|
297
|
+
return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
|
|
298
|
+
}
|
|
299
|
+
/** Words that make a field secret, whatever else it is called. */
|
|
300
|
+
var SECRET_WORDS = new RegExp("\\b(?:" + [
|
|
301
|
+
"pass(?:word|wd|code|phrase|key)s?",
|
|
302
|
+
"pwd",
|
|
303
|
+
"pw",
|
|
304
|
+
"secrets?",
|
|
305
|
+
"tokens?",
|
|
306
|
+
"api ?keys?",
|
|
307
|
+
"access ?keys?",
|
|
308
|
+
"secret ?keys?",
|
|
309
|
+
"private ?keys?",
|
|
310
|
+
"client ?secrets?",
|
|
311
|
+
"licen[cs]e ?keys?",
|
|
312
|
+
"product ?keys?",
|
|
313
|
+
"activation ?(?:keys?|codes?)",
|
|
314
|
+
"recovery ?(?:keys?|codes?)",
|
|
315
|
+
"backup ?codes?",
|
|
316
|
+
"auth(?:entication|orization)? ?codes?",
|
|
317
|
+
"otp",
|
|
318
|
+
"totp",
|
|
319
|
+
"one ?time ?(?:codes?|passwords?|passcodes?|pins?)",
|
|
320
|
+
"(?:verification|security|confirmation|sms|2fa|mfa) ?codes?",
|
|
321
|
+
"2fa",
|
|
322
|
+
"mfa",
|
|
323
|
+
"cvv2?",
|
|
324
|
+
"cvc2?",
|
|
325
|
+
"csc",
|
|
326
|
+
"card ?(?:numbers?|num|no|codes?|verification|holder|security|expiry|expiration|exp|cvc|cvv)",
|
|
327
|
+
"name ?on ?card",
|
|
328
|
+
"credit ?cards?",
|
|
329
|
+
"debit ?cards?",
|
|
330
|
+
"cc ?(?:number|num|no|exp|expiry|csc|cvc|cvv|name|given|family|type)",
|
|
331
|
+
"iban",
|
|
332
|
+
"account ?(?:numbers?|num|no)",
|
|
333
|
+
"routing ?(?:numbers?|num|no)",
|
|
334
|
+
"sort ?codes?",
|
|
335
|
+
"ssn",
|
|
336
|
+
"social ?security",
|
|
337
|
+
"tax ?id",
|
|
338
|
+
"national ?id",
|
|
339
|
+
"passports?",
|
|
340
|
+
"pins?(?: ?codes?)?",
|
|
341
|
+
"(?:recovery|seed|secret|backup|mnemonic) ?(?:phrases?|words?)",
|
|
342
|
+
"mnemonics?",
|
|
343
|
+
"(?:security|secret) ?(?:questions?|answers?)",
|
|
344
|
+
"memorable ?(?:words?|information|info)",
|
|
345
|
+
"mother s maiden name"
|
|
346
|
+
].join("|") + ")\\b");
|
|
347
|
+
/** The same in Arabic (password, secret code, verification code, card number, account number). */
|
|
348
|
+
var SECRET_WORDS_AR = /كلمة (?:ال)?(?:مرور|سر)|(?:ال)?رمز (?:ال)?(?:سري|تحقق|التحقق|أمان|الأمان|المرور)|رقم (?:ال)?(?:بطاقة|حساب|سري)|آيبان/;
|
|
349
|
+
/** Whether a field described so (its type, name, id, autocomplete, label and placeholder, in one text) holds a secret. */
|
|
350
|
+
function isSecretHint(hint) {
|
|
351
|
+
return SECRET_WORDS.test(words$1(hint)) || SECRET_WORDS_AR.test(hint);
|
|
352
|
+
}
|
|
353
|
+
/** Words a field's label adds that don't tell one secret from another. */
|
|
354
|
+
var FILLER = /* @__PURE__ */ new Set([
|
|
355
|
+
"enter",
|
|
356
|
+
"your",
|
|
357
|
+
"the",
|
|
358
|
+
"a",
|
|
359
|
+
"an",
|
|
360
|
+
"please",
|
|
361
|
+
"type",
|
|
362
|
+
"confirm",
|
|
363
|
+
"confirmation",
|
|
364
|
+
"repeat",
|
|
365
|
+
"again",
|
|
366
|
+
"re",
|
|
367
|
+
"retype",
|
|
368
|
+
"field",
|
|
369
|
+
"here",
|
|
370
|
+
"of",
|
|
371
|
+
"required",
|
|
372
|
+
"optional"
|
|
373
|
+
]);
|
|
374
|
+
/** A word before a secret that does tell it from another: a new password isn't the current one. */
|
|
375
|
+
var QUALIFIER = /* @__PURE__ */ new Set([
|
|
376
|
+
"new",
|
|
377
|
+
"old",
|
|
378
|
+
"current",
|
|
379
|
+
"previous",
|
|
380
|
+
"admin",
|
|
381
|
+
"master",
|
|
382
|
+
"root"
|
|
383
|
+
]);
|
|
384
|
+
/**
|
|
385
|
+
* What a secret is called in a test, from its field's label (or name): “Enter your password” → PASSWORD, “Card
|
|
386
|
+
* number” → CARD_NUMBER, “New password” → NEW_PASSWORD, “Password (8 characters or more)” → PASSWORD; “Confirm
|
|
387
|
+
* password” is PASSWORD too, so one value fills both.
|
|
388
|
+
*/
|
|
389
|
+
function secretName(label) {
|
|
390
|
+
const w = words$1(label);
|
|
391
|
+
const m = SECRET_WORDS.exec(w);
|
|
392
|
+
let name;
|
|
393
|
+
if (m) {
|
|
394
|
+
const before = w.slice(0, m.index).trim().split(" ").pop() ?? "";
|
|
395
|
+
name = [...QUALIFIER.has(before) ? [before] : [], ...m[0].split(" ")];
|
|
396
|
+
} else name = w.split(" ").filter((x) => x && !FILLER.has(x) && /^[a-z0-9]+$/.test(x)).slice(0, 3);
|
|
397
|
+
if (name.length) return name.join("_").toUpperCase().slice(0, 40);
|
|
398
|
+
if (/كلمة (?:ال)?(?:مرور|سر)/.test(label)) return "PASSWORD";
|
|
399
|
+
if (/تحقق|التحقق/.test(label)) return "VERIFICATION_CODE";
|
|
400
|
+
if (/بطاقة/.test(label)) return "CARD_NUMBER";
|
|
401
|
+
return "SECRET";
|
|
402
|
+
}
|
|
403
|
+
/** A secret's name as an environment variable can take it: capitals, digits and underscores. */
|
|
404
|
+
function secretEnvName(name) {
|
|
405
|
+
return SECRET_ENV_PREFIX + (name.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "SECRET");
|
|
406
|
+
}
|
|
407
|
+
/** What may be a card number: digits with spaces or dashes between them (isCard decides). */
|
|
408
|
+
var CARD = /\b\d(?:[ -]?\d){12,22}\b/g;
|
|
409
|
+
/** Keys and tokens by their shape. */
|
|
410
|
+
var TOKENS = [
|
|
411
|
+
/\beyJ[\w-]{6,}\.eyJ[\w-]{6,}\.[\w-]{6,}/g,
|
|
412
|
+
/\b(?:sk|rk)-[\w-]{16,}/g,
|
|
413
|
+
/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g,
|
|
414
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
415
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
416
|
+
/\bglpat-[\w-]{16,}/g,
|
|
417
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
|
|
418
|
+
/\bnpm_[A-Za-z0-9]{30,}/g,
|
|
419
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
420
|
+
/\bAIza[0-9A-Za-z_-]{35}/g,
|
|
421
|
+
/\bwhsec_[A-Za-z0-9]{16,}/g,
|
|
422
|
+
/\bSG\.[\w-]{16,}\.[\w-]{16,}/g,
|
|
423
|
+
/(?<=\b(?:aws_?secret_?access_?key|secret_?access_?key|client_?secret|api_?secret|private_?key|access_?token|refresh_?token|auth_?token|password|passwd)["']?\s*[:=]\s*["']?)[^\s"',;}{]{8,}/gi,
|
|
424
|
+
/\b[a-z]{2,8}_(?:(?:live|test)_)?(?=[A-Za-z0-9]{20,}\b)(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[a-z])(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]{20,}\b/g,
|
|
425
|
+
/\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/g,
|
|
426
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g
|
|
427
|
+
];
|
|
428
|
+
function luhn(digits) {
|
|
429
|
+
let sum = 0;
|
|
430
|
+
for (let i = 0; i < digits.length; i++) {
|
|
431
|
+
let d = Number(digits[digits.length - 1 - i]);
|
|
432
|
+
if (i % 2) d = d * 2 > 9 ? d * 2 - 9 : d * 2;
|
|
433
|
+
sum += d;
|
|
434
|
+
}
|
|
435
|
+
return sum % 10 === 0;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Whether digits are written as a card number is: 13 to 19 of them, starting with 2 to 6 (as cards of every network
|
|
439
|
+
* do), passing the Luhn check, and either unbroken or in groups of 4 to 6 (the last one shorter) with one kind of
|
|
440
|
+
* separator. Not a date with a number either side of it, an order row or a timestamp.
|
|
441
|
+
*/
|
|
442
|
+
function isCard(text) {
|
|
443
|
+
const d = text.replace(/\D/g, "");
|
|
444
|
+
if (d.length < 13 || d.length > 19 || !/^[2-6]/.test(d) || !luhn(d)) return false;
|
|
445
|
+
if (/^\d+$/.test(text)) return true;
|
|
446
|
+
if (text.includes(" ") && text.includes("-")) return false;
|
|
447
|
+
const groups = text.split(/[ -]/);
|
|
448
|
+
return groups.every((g) => /^\d+$/.test(g)) && groups.slice(0, -1).every((g) => g.length >= 4 && g.length <= 6) && groups[groups.length - 1].length <= 6;
|
|
449
|
+
}
|
|
450
|
+
/** A card number in `run` (digits with spaces or dashes), with what follows it kept: the longest first part that is one. */
|
|
451
|
+
function maskCard(run) {
|
|
452
|
+
const parts = run.split(/(?<=\d)(?=[ -])/);
|
|
453
|
+
for (let k = parts.length; k > 0; k--) {
|
|
454
|
+
const head = parts.slice(0, k).join("");
|
|
455
|
+
if (isCard(head)) return `•••• ${head.replace(/\D/g, "").slice(-4)}${parts.slice(k).join("")}`;
|
|
456
|
+
}
|
|
457
|
+
return run;
|
|
458
|
+
}
|
|
459
|
+
/** Text with every card number (kept: its last four digits) and every key- or token-shaped string in it masked. */
|
|
460
|
+
function maskSecrets(text) {
|
|
461
|
+
if (!text) return text;
|
|
462
|
+
let out = text.replace(CARD, maskCard);
|
|
463
|
+
out = out.replace(/\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)([^\s@/]+)@/gi, (_m, head) => `${head}${MASK}@`);
|
|
464
|
+
for (const re of TOKENS) out = out.replace(re, (m) => m.startsWith("Bearer") ? `Bearer ${MASK}` : MASK);
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Whether a typed value is a secret by its look alone: a card number, a key or a token, or a long random string (of
|
|
469
|
+
* letters in both cases and digits, as keys are; or 32 hexadecimal digits or more). A slug, a sentence, an email
|
|
470
|
+
* address or a coupon code is not.
|
|
471
|
+
*/
|
|
472
|
+
function looksSecret(value) {
|
|
473
|
+
const v = value.trim();
|
|
474
|
+
if (v.length < 12) return false;
|
|
475
|
+
if (maskSecrets(v) !== v) return true;
|
|
476
|
+
if (/^[a-f0-9]{32,}$/i.test(v) && /\d/.test(v) && /[a-f]/i.test(v)) return true;
|
|
477
|
+
return /^[A-Za-z0-9+/_=-]{24,}$/.test(v) && /\d/.test(v) && /[a-z]/.test(v) && /[A-Z]/.test(v);
|
|
478
|
+
}
|
|
479
|
+
/** Query and hash parameters whose value is a secret by their name alone. */
|
|
480
|
+
var SECRET_PARAM = /^(?:access_?token|refresh_?token|id_?token|auth_?token|token|auth|authorization|api_?key|apikey|key|secret|client_?secret|password|passwd|pwd|pass|otp|code_?verifier|jwt|session|session_?id|sessionid|sid|signature|sig|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential)$/i;
|
|
481
|
+
/** Addresses of sign-in steps, where `code`, `state` and `nonce` carry what lets someone in. */
|
|
482
|
+
var AUTH_PATH = /(?:^|\/)(?:callback|oauth2?|auth|authorize|sso|login|signin|sign-in|verify|magic|reset|confirm)(?:[/?#.]|$)/i;
|
|
483
|
+
function decoded(s) {
|
|
484
|
+
try {
|
|
485
|
+
return decodeURIComponent(s.replace(/\+/g, " "));
|
|
486
|
+
} catch {
|
|
487
|
+
return s;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/** `a=1&b=2` with the values of secret parameters masked, and key- or token-shaped values too. */
|
|
491
|
+
function maskParams(params, authPath) {
|
|
492
|
+
return params.split("&").map((pair) => {
|
|
493
|
+
const eq = pair.indexOf("=");
|
|
494
|
+
if (eq < 0) return maskSecrets(pair);
|
|
495
|
+
const name = decoded(pair.slice(0, eq));
|
|
496
|
+
const value = pair.slice(eq + 1);
|
|
497
|
+
if (!value) return pair;
|
|
498
|
+
const secret = SECRET_PARAM.test(name) || isSecretHint(name) || authPath && /^(?:code|state|nonce)$/i.test(name) || looksSecret(decoded(value));
|
|
499
|
+
return `${pair.slice(0, eq)}=${secret ? MASK : maskSecrets(value)}`;
|
|
500
|
+
}).join("&");
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* An address with the secrets in it masked: the values of parameters named like a token, key, password or session
|
|
504
|
+
* (in its query, and in its hash: `#access_token=…`), sign-in codes, and key- or token-shaped parts of its path.
|
|
505
|
+
* Works on whole and relative addresses, and never throws.
|
|
506
|
+
*/
|
|
507
|
+
function redactUrl(url) {
|
|
508
|
+
if (!url) return url;
|
|
509
|
+
const hashAt = url.indexOf("#");
|
|
510
|
+
const base = hashAt < 0 ? url : url.slice(0, hashAt);
|
|
511
|
+
const hash = hashAt < 0 ? null : url.slice(hashAt + 1);
|
|
512
|
+
const q = base.indexOf("?");
|
|
513
|
+
const path = q < 0 ? base : base.slice(0, q);
|
|
514
|
+
const authPath = AUTH_PATH.test(path);
|
|
515
|
+
let out = maskSecrets(path).replace(/(?<=\/)[A-Za-z0-9_-]{24,}(?=\/|$)/g, (part) => /\d/.test(part) && /[a-z]/.test(part) && /[A-Z]/.test(part) ? MASK : part);
|
|
516
|
+
if (q >= 0) out += `?${maskParams(base.slice(q + 1), authPath)}`;
|
|
517
|
+
if (hash !== null) {
|
|
518
|
+
const hq = hash.indexOf("?");
|
|
519
|
+
if (hq >= 0) out += `#${maskSecrets(hash.slice(0, hq))}?${maskParams(hash.slice(hq + 1), authPath || AUTH_PATH.test(hash.slice(0, hq)))}`;
|
|
520
|
+
else out += `#${hash.includes("=") ? maskParams(hash, authPath) : maskSecrets(hash)}`;
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
/** A recorded element's words, masked: text it showed may hold a card number or a key. */
|
|
525
|
+
function scrubTarget(el) {
|
|
526
|
+
if (!el || typeof el !== "object") return;
|
|
527
|
+
const t = el;
|
|
528
|
+
if (typeof t.label === "string") t.label = maskSecrets(t.label);
|
|
529
|
+
if (typeof t.text === "string") t.text = maskSecrets(t.text);
|
|
530
|
+
if (typeof t.description === "string") t.description = maskSecrets(t.description);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* A session event as it may be kept: addresses with their secrets masked (see redactUrl), card numbers and keys
|
|
534
|
+
* masked in every text it carries (errors, console lines, response bodies, what the screen said), and a typed value
|
|
535
|
+
* that is a secret (by its field's name, or its look) as dots. The bridge records events so; the studio scrubs
|
|
536
|
+
* what arrives again, for events an older plugin recorded. Changes the event in place, and returns it.
|
|
537
|
+
*/
|
|
538
|
+
function scrubEvent(event) {
|
|
539
|
+
const e = event;
|
|
540
|
+
const url = (k) => {
|
|
541
|
+
if (typeof e[k] === "string") e[k] = redactUrl(e[k]);
|
|
542
|
+
};
|
|
543
|
+
const text = (k) => {
|
|
544
|
+
if (typeof e[k] === "string") e[k] = maskSecrets(e[k]);
|
|
545
|
+
};
|
|
546
|
+
scrubTarget(e.el);
|
|
547
|
+
switch (e.type) {
|
|
548
|
+
case "page":
|
|
549
|
+
url("url");
|
|
550
|
+
url("referrer");
|
|
551
|
+
break;
|
|
552
|
+
case "nav":
|
|
553
|
+
url("url");
|
|
554
|
+
break;
|
|
555
|
+
case "net":
|
|
556
|
+
url("url");
|
|
557
|
+
text("body");
|
|
558
|
+
text("error");
|
|
559
|
+
break;
|
|
560
|
+
case "error":
|
|
561
|
+
case "console":
|
|
562
|
+
text("message");
|
|
563
|
+
text("text");
|
|
564
|
+
text("stack");
|
|
565
|
+
break;
|
|
566
|
+
case "dom":
|
|
567
|
+
url("url");
|
|
568
|
+
text("text");
|
|
569
|
+
if (Array.isArray(e.headings)) e.headings = e.headings.map((h) => typeof h === "string" ? maskSecrets(h) : h);
|
|
570
|
+
break;
|
|
571
|
+
case "input": {
|
|
572
|
+
const target = e.el ?? {};
|
|
573
|
+
const value = typeof e.value === "string" ? e.value : "";
|
|
574
|
+
if (!e.masked && (isSecretHint(`${target.label ?? ""} ${target.name ?? ""}`) || looksSecret(value))) {
|
|
575
|
+
e.value = dots(value);
|
|
576
|
+
e.masked = true;
|
|
577
|
+
e.secret ??= secretName(target.label || target.name || "");
|
|
578
|
+
} else if (!e.masked) text("value");
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
case "change":
|
|
582
|
+
text("value");
|
|
583
|
+
if (Array.isArray(e.options)) e.options = e.options.map((o) => typeof o === "string" ? maskSecrets(o) : o);
|
|
584
|
+
}
|
|
585
|
+
return event;
|
|
586
|
+
}
|
|
277
587
|
/** Where the studio runs unless told otherwise. Not 5173: that is Vite's own default, where your app likely runs. */
|
|
278
588
|
var STUDIO_PORT = 7357;
|
|
589
|
+
/**
|
|
590
|
+
* Through the studio's proxy, a page's XMLHttpRequest to another port of this computer says with this header that it
|
|
591
|
+
* was sent with its credentials (withCredentials, set after open): the proxy then sends the site's cookies with it.
|
|
592
|
+
*/
|
|
593
|
+
var PROXY_CREDENTIALS_HEADER = "x-saywright-credentials";
|
|
279
594
|
var STUDIO_URL = `http://localhost:${STUDIO_PORT}`;
|
|
280
595
|
var JS_ESCAPES = {
|
|
281
596
|
"\\": "\\\\",
|
|
@@ -600,6 +915,31 @@ function safeDirs(dir) {
|
|
|
600
915
|
return [];
|
|
601
916
|
}
|
|
602
917
|
}
|
|
918
|
+
/** Where `connect` adds the studio's script tag (and where a person would): a root layout, a document, the page. */
|
|
919
|
+
var SCRIPT_FILES = [
|
|
920
|
+
"src/app/layout.tsx",
|
|
921
|
+
"app/layout.tsx",
|
|
922
|
+
"src/app/layout.jsx",
|
|
923
|
+
"app/layout.jsx",
|
|
924
|
+
"src/app/layout.js",
|
|
925
|
+
"app/layout.js",
|
|
926
|
+
"src/pages/_document.tsx",
|
|
927
|
+
"pages/_document.tsx",
|
|
928
|
+
"src/pages/_document.jsx",
|
|
929
|
+
"pages/_document.jsx",
|
|
930
|
+
"src/pages/_document.js",
|
|
931
|
+
"pages/_document.js",
|
|
932
|
+
"public/index.html",
|
|
933
|
+
"index.html",
|
|
934
|
+
"src/index.html"
|
|
935
|
+
];
|
|
936
|
+
/** Whether the app's own pages load the studio's script (connected with its script tag): no proxy needed. */
|
|
937
|
+
function hasScriptTag(dir) {
|
|
938
|
+
for (const rel of SCRIPT_FILES) try {
|
|
939
|
+
if (BRIDGE_SCRIPT.test(fs.readFileSync(path.join(dir, rel), "utf8").slice(0, 2e5))) return true;
|
|
940
|
+
} catch {}
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
603
943
|
/** Whether a Vite config already loads the studio's plugin. */
|
|
604
944
|
function hasPlugin(dir) {
|
|
605
945
|
const config = first(dir, ...VITE_CONFIGS);
|
|
@@ -626,6 +966,7 @@ function probeFolder(input) {
|
|
|
626
966
|
const notes = [];
|
|
627
967
|
const { start, port, guessed } = devCommandOf(root, pkg, runs, framework);
|
|
628
968
|
const plugin = hasPlugin(root);
|
|
969
|
+
const script = !plugin && hasScriptTag(root);
|
|
629
970
|
const canAddPlugin = VITE_BASED.has(framework) && !plugin && !!declaredDeps(root).react;
|
|
630
971
|
const candidates = pkg ? workspaceApps(root, pkg) : void 0;
|
|
631
972
|
if (!pkg && framework === "static") notes.push("No package.json: the studio serves this folder as a static site.");
|
|
@@ -633,6 +974,7 @@ function probeFolder(input) {
|
|
|
633
974
|
if (pkg && !start && framework !== "static") notes.push("No dev, start or serve script: start the app yourself and give its address.");
|
|
634
975
|
if (candidates?.length && !start) notes.push(`A monorepo with ${candidates.length} app${candidates.length === 1 ? "" : "s"}: pick one.`);
|
|
635
976
|
if (canAddPlugin) notes.push("A Vite + React app: adding the Saywright plugin maps every element to its exact line of code.");
|
|
977
|
+
else if (script) notes.push("Its pages load the studio’s script: shown as it is, without the proxy.");
|
|
636
978
|
else if (!plugin && pkg) notes.push("Shown through the studio’s proxy: no change to your code needed.");
|
|
637
979
|
if (start && pmNote) notes.push(pmNote);
|
|
638
980
|
if (start && guessed) notes.push(portNote(guessed, framework, port));
|
|
@@ -648,7 +990,7 @@ function probeFolder(input) {
|
|
|
648
990
|
needsInstall,
|
|
649
991
|
plugin,
|
|
650
992
|
canAddPlugin,
|
|
651
|
-
connection: plugin ? "plugin" : "proxy",
|
|
993
|
+
connection: plugin ? "plugin" : script ? "script" : "proxy",
|
|
652
994
|
repo: fs.existsSync(path.join(root, ".git")) ? gitRemote(root) : void 0,
|
|
653
995
|
notes,
|
|
654
996
|
candidates: candidates?.length ? candidates : void 0
|
|
@@ -969,6 +1311,22 @@ ${c.bold("saywright connect")} <app folder> [options]: connect an app to the stu
|
|
|
969
1311
|
--studio <address> where the studio runs, if not on ${STUDIO_URL} (the script tag loads from it)
|
|
970
1312
|
--dry-run show what would change, change nothing
|
|
971
1313
|
`;
|
|
1314
|
+
/** Where the studio that runs now listens, as it wrote when it started: null when none runs. */
|
|
1315
|
+
function runningStudio(dataDir) {
|
|
1316
|
+
let saved;
|
|
1317
|
+
try {
|
|
1318
|
+
saved = JSON.parse(fs.readFileSync(path.join(dataDir, "server.json"), "utf8"));
|
|
1319
|
+
} catch {
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
1322
|
+
if (typeof saved.url !== "string" || !/^https?:\/\//.test(saved.url) || typeof saved.pid !== "number") return null;
|
|
1323
|
+
try {
|
|
1324
|
+
process.kill(saved.pid, 0);
|
|
1325
|
+
} catch (err) {
|
|
1326
|
+
if (err.code !== "EPERM") return null;
|
|
1327
|
+
}
|
|
1328
|
+
return saved.url;
|
|
1329
|
+
}
|
|
972
1330
|
async function connect(argv, ctx) {
|
|
973
1331
|
const noStart = argv.includes("--no-start");
|
|
974
1332
|
const { values, positionals } = parseArgs({
|
|
@@ -991,7 +1349,7 @@ async function connect(argv, ctx) {
|
|
|
991
1349
|
return values.help ? 0 : 1;
|
|
992
1350
|
}
|
|
993
1351
|
const dry = !!values["dry-run"];
|
|
994
|
-
const studio = (values.studio ?? STUDIO_URL).replace(/\/+$/, "");
|
|
1352
|
+
const studio = (values.studio ?? runningStudio(ctx.layout.dataDir) ?? STUDIO_URL).replace(/\/+$/, "");
|
|
995
1353
|
const scriptUrl = `${studio}/saywright.js`;
|
|
996
1354
|
const root = path.resolve(positionals[0]);
|
|
997
1355
|
const pkg = readPkg(root);
|
|
@@ -1224,15 +1582,11 @@ async function extractTarBz2(archive, dest) {
|
|
|
1224
1582
|
/**
|
|
1225
1583
|
* Models the studio's page runs itself, downloaded once into the data folder and served at /models/:
|
|
1226
1584
|
*
|
|
1227
|
-
* hand_landmarker.task MediaPipe's hand model (7.8 MB), for hand control (Settings → Labs). Without it the
|
|
1228
|
-
* page loads the same file from Google's servers.
|
|
1229
1585
|
* moonshine/tiny/*.onnx Moonshine tiny (English, MIT, 28 MB): voice commands understood in the browser,
|
|
1230
1586
|
* without the browser's own recogniser. Without it, that recogniser alone.
|
|
1231
1587
|
*/
|
|
1232
|
-
var HAND_MODEL_URL = "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
|
|
1233
1588
|
var MOONSHINE_TGZ = "https://registry.npmjs.org/@moonshine-ai/moonshine-js/-/moonshine-js-0.1.29.tgz";
|
|
1234
1589
|
var MOONSHINE_SHA256 = "864e17a00ed494857c71693e835306fa038e0f82002c76e3056d67d574e1c2d8";
|
|
1235
|
-
var HAND_MODEL_SHA256 = "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1";
|
|
1236
1590
|
var MOONSHINE_FILES = {
|
|
1237
1591
|
"package/dist/model/tiny/quantized/encoder_model.onnx": {
|
|
1238
1592
|
name: "encoder_model.onnx",
|
|
@@ -1244,40 +1598,10 @@ var MOONSHINE_FILES = {
|
|
|
1244
1598
|
}
|
|
1245
1599
|
};
|
|
1246
1600
|
var modelsDir = (dataDir) => path.join(dataDir, "models");
|
|
1247
|
-
var handModelFile = (dataDir) => path.join(modelsDir(dataDir), "hand_landmarker.task");
|
|
1248
1601
|
var moonshineDir = (dataDir) => path.join(modelsDir(dataDir), "moonshine", "tiny");
|
|
1249
|
-
/** A .task file is a zip archive of TFLite models: check its size and the zip signature. */
|
|
1250
|
-
function isHandModel(file) {
|
|
1251
|
-
try {
|
|
1252
|
-
if (fs.statSync(file).size < 1e6) return false;
|
|
1253
|
-
const head = Buffer.alloc(16);
|
|
1254
|
-
const fd = fs.openSync(file, "r");
|
|
1255
|
-
fs.readSync(fd, head, 0, 16, 0);
|
|
1256
|
-
fs.closeSync(fd);
|
|
1257
|
-
return head.includes(Buffer.from([
|
|
1258
|
-
80,
|
|
1259
|
-
75,
|
|
1260
|
-
3,
|
|
1261
|
-
4
|
|
1262
|
-
]));
|
|
1263
|
-
} catch {
|
|
1264
|
-
return false;
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
1602
|
function browserSpeechReady(dataDir) {
|
|
1268
1603
|
return Object.values(MOONSHINE_FILES).every((f) => fs.statSync(path.join(moonshineDir(dataDir), f.name), { throwIfNoEntry: false })?.size === f.size);
|
|
1269
1604
|
}
|
|
1270
|
-
async function ensureHandModel(dataDir) {
|
|
1271
|
-
const file = handModelFile(dataDir);
|
|
1272
|
-
if (isHandModel(file)) return;
|
|
1273
|
-
const tmp = `${file}.download`;
|
|
1274
|
-
await download(HAND_MODEL_URL, tmp, void 0, HAND_MODEL_SHA256);
|
|
1275
|
-
if (!isHandModel(tmp)) {
|
|
1276
|
-
fs.rmSync(tmp, { force: true });
|
|
1277
|
-
throw new Error("The hand model download was incomplete.");
|
|
1278
|
-
}
|
|
1279
|
-
fs.renameSync(tmp, file);
|
|
1280
|
-
}
|
|
1281
1605
|
/** Pull the wanted files out of a .tar (ustar, regular files only). */
|
|
1282
1606
|
function untar(buf, wanted) {
|
|
1283
1607
|
const out = {};
|
|
@@ -1954,7 +2278,6 @@ async function doctor(argv, ctx) {
|
|
|
1954
2278
|
if (ctx.clientDir && !fs.existsSync(path.join(ctx.clientDir, "index.html"))) bad("The studio’s interface isn’t built", "Reinstall Saywright (npx @saywright/studio@latest).");
|
|
1955
2279
|
if (browserSpeechReady(layout.dataDir)) ok("On-device voice model");
|
|
1956
2280
|
else warn("On-device voice model not downloaded: voice uses the browser’s recogniser alone", `Run ${ctx.clientDir ? "npx @saywright/studio setup" : "npm run setup"} to download it once (28 MB).`);
|
|
1957
|
-
if (isHandModel(handModelFile(layout.dataDir))) ok("Hand model (for hand control, in Settings → Labs)");
|
|
1958
2281
|
const speech = new SpeechService(speechDir(layout), layout.speechWorker, () => void 0).status();
|
|
1959
2282
|
if (speech.runtime === "missing") warn("The speech engine can’t run on this computer", "Narration is still recorded; voice uses the browser’s recogniser.");
|
|
1960
2283
|
else {
|
|
@@ -2625,7 +2948,6 @@ function runQuiet(command, args, timeoutMs = 6e3, env) {
|
|
|
2625
2948
|
*
|
|
2626
2949
|
* On-device voice model (28 MB) voice commands understood without the browser's recogniser
|
|
2627
2950
|
* Speech models, Arabic and English narration of test sessions transcribed on this computer, Arabic voice
|
|
2628
|
-
* Hand model (7.8 MB, --hand) hand control (Settings → Labs) without loading it from Google
|
|
2629
2951
|
* Test browser (in the repository) running generated tests against the sample app
|
|
2630
2952
|
*/
|
|
2631
2953
|
var HELP$2 = `
|
|
@@ -2634,7 +2956,6 @@ ${c.bold("saywright setup")} [options]: download the optional extras
|
|
|
2634
2956
|
--no-speech skip the speech models (Arabic and English, about 740 MB)
|
|
2635
2957
|
--best-arabic add the most accurate Arabic model without asking (1.6 GB more)
|
|
2636
2958
|
--no-best-arabic don't add it, don't ask
|
|
2637
|
-
--hand add the hand model, for hand control (Settings → Labs)
|
|
2638
2959
|
`;
|
|
2639
2960
|
/** One line that redraws itself, in a terminal; `done` clears it. */
|
|
2640
2961
|
function progress(label) {
|
|
@@ -2686,7 +3007,7 @@ async function setup(argv, ctx) {
|
|
|
2686
3007
|
console.log(c.bold("\nSaywright setup\n"));
|
|
2687
3008
|
if (!browserSpeechReady(dataDir)) console.log(c.dim("… On-device voice model (28 MB)"));
|
|
2688
3009
|
await attempt("On-device voice model", () => ensureBrowserSpeech(dataDir), "Voice uses the browser’s recogniser alone until it is there.");
|
|
2689
|
-
if (values.hand)
|
|
3010
|
+
if (values.hand) console.log(c.dim("Hand control was removed in 0.2.0: there is no hand model to download."));
|
|
2690
3011
|
if (values.speech) {
|
|
2691
3012
|
const speech = new SpeechService(speechDir(ctx.layout), ctx.layout.speechWorker, () => void 0);
|
|
2692
3013
|
const status = speech.status();
|
|
@@ -3236,7 +3557,7 @@ async function readIgnore(root, dir) {
|
|
|
3236
3557
|
if (dirOnly) line = line.slice(0, -1);
|
|
3237
3558
|
const anchored = line.includes("/");
|
|
3238
3559
|
line = line.replace(/^\//, "");
|
|
3239
|
-
const base = dir ? `${escapeRe(dir)}/` : "";
|
|
3560
|
+
const base = dir ? `${escapeRe$1(dir)}/` : "";
|
|
3240
3561
|
rules.push({
|
|
3241
3562
|
re: new RegExp(`^${base}${anchored ? "" : "(?:.*/)?"}${globToRe(line)}$`),
|
|
3242
3563
|
negate,
|
|
@@ -3250,7 +3571,7 @@ function ignored(rules, rel, isDir) {
|
|
|
3250
3571
|
for (const r of rules) if ((!r.dirOnly || isDir) && r.re.test(rel)) out = !r.negate;
|
|
3251
3572
|
return out;
|
|
3252
3573
|
}
|
|
3253
|
-
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3574
|
+
var escapeRe$1 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3254
3575
|
function globToRe(glob) {
|
|
3255
3576
|
let re = "";
|
|
3256
3577
|
for (let i = 0; i < glob.length; i++) {
|
|
@@ -3268,8 +3589,8 @@ function globToRe(glob) {
|
|
|
3268
3589
|
re += `[${glob.slice(i + 1, end).replace(/^!/, "^").replace(/\\/g, "\\\\")}]`;
|
|
3269
3590
|
i = end;
|
|
3270
3591
|
}
|
|
3271
|
-
} else if (c === "\\" && i + 1 < glob.length) re += escapeRe(glob[++i]);
|
|
3272
|
-
else re += escapeRe(c);
|
|
3592
|
+
} else if (c === "\\" && i + 1 < glob.length) re += escapeRe$1(glob[++i]);
|
|
3593
|
+
else re += escapeRe$1(c);
|
|
3273
3594
|
}
|
|
3274
3595
|
return re;
|
|
3275
3596
|
}
|
|
@@ -15124,6 +15445,12 @@ var IGNORED_DIRS$1 = /* @__PURE__ */ new Set([
|
|
|
15124
15445
|
".turbo",
|
|
15125
15446
|
".vercel"
|
|
15126
15447
|
]);
|
|
15448
|
+
/** Refuses a path (relative to the project or its workspace) in a folder that isn't the app's code, or an environment file. */
|
|
15449
|
+
function offLimits(relative, asked) {
|
|
15450
|
+
const parts = relative.split(path.sep);
|
|
15451
|
+
if (IGNORED_DIRS$1.has(parts[0]) || parts.some((p) => p === "node_modules" || p === ".git")) throw new HttpError(400, `Path is not editable: ${asked}`);
|
|
15452
|
+
if (parts.some((p) => p.startsWith(".env"))) throw new HttpError(400, "Environment files are off limits");
|
|
15453
|
+
}
|
|
15127
15454
|
var TEXT_EXT = /\.([cm]?[jt]sx?|css|scss|sass|less|styl|json|md|mdx|html?|svg|vue|svelte|astro|php|erb|ejs|hbs|handlebars|mustache|njk|liquid|twig|jinja2?|j2|cshtml|razor|py|rb|go|ya?ml|toml|xml|txt)$/i;
|
|
15128
15455
|
/** Folders never searched: dependencies, builds, tools' caches and other dot folders (as listFiles walks them). */
|
|
15129
15456
|
var unsearched = (name) => IGNORED_DIRS$1.has(name) || name.startsWith(".") && name !== ".storybook";
|
|
@@ -15233,18 +15560,19 @@ var Project = class {
|
|
|
15233
15560
|
const abs = path.resolve(this.root, clean);
|
|
15234
15561
|
const relative = this.inside(abs);
|
|
15235
15562
|
if (!relative) throw new HttpError(400, `Path is outside the project: ${rel}`);
|
|
15236
|
-
|
|
15237
|
-
|
|
15238
|
-
if (
|
|
15239
|
-
|
|
15563
|
+
offLimits(relative, rel);
|
|
15564
|
+
const real = this.realTarget(abs);
|
|
15565
|
+
if (!real) throw new HttpError(400, `Path is outside the project: ${rel}`);
|
|
15566
|
+
offLimits(real, rel);
|
|
15240
15567
|
return abs;
|
|
15241
15568
|
}
|
|
15242
15569
|
realRoot = null;
|
|
15243
15570
|
/**
|
|
15244
|
-
*
|
|
15245
|
-
* judged by the folder it would be written in; a link that leads
|
|
15571
|
+
* Where a path leads with every link along it followed, relative to the project (or its workspace); null when that
|
|
15572
|
+
* is outside both. A file that doesn't exist yet is judged by the folder it would be written in; a link that leads
|
|
15573
|
+
* nowhere is refused (writing would create its target).
|
|
15246
15574
|
*/
|
|
15247
|
-
|
|
15575
|
+
realTarget(abs) {
|
|
15248
15576
|
this.realRoot ??= fs.realpathSync(this.root);
|
|
15249
15577
|
let probe = abs;
|
|
15250
15578
|
const rest = [];
|
|
@@ -15253,7 +15581,7 @@ var Project = class {
|
|
|
15253
15581
|
break;
|
|
15254
15582
|
} catch {
|
|
15255
15583
|
const parent = path.dirname(probe);
|
|
15256
|
-
if (parent === probe) return
|
|
15584
|
+
if (parent === probe) return null;
|
|
15257
15585
|
rest.unshift(path.basename(probe));
|
|
15258
15586
|
probe = parent;
|
|
15259
15587
|
}
|
|
@@ -15261,13 +15589,14 @@ var Project = class {
|
|
|
15261
15589
|
try {
|
|
15262
15590
|
real = fs.realpathSync(probe);
|
|
15263
15591
|
} catch {
|
|
15264
|
-
return
|
|
15592
|
+
return null;
|
|
15265
15593
|
}
|
|
15266
15594
|
const target = path.join(real, ...rest);
|
|
15267
|
-
|
|
15595
|
+
for (const root of [this.realRoot, this.workspace && fs.realpathSync(this.workspace)]) {
|
|
15268
15596
|
const relative = root ? path.relative(root, target) : "";
|
|
15269
|
-
|
|
15270
|
-
}
|
|
15597
|
+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) return relative;
|
|
15598
|
+
}
|
|
15599
|
+
return null;
|
|
15271
15600
|
}
|
|
15272
15601
|
rel(abs) {
|
|
15273
15602
|
return path.relative(this.root, abs).split(path.sep).join("/");
|
|
@@ -15286,12 +15615,17 @@ var Project = class {
|
|
|
15286
15615
|
}
|
|
15287
15616
|
/** Atomic write: temp file + rename, so the dev server never sees half a file. */
|
|
15288
15617
|
write(rel, content) {
|
|
15289
|
-
const
|
|
15618
|
+
const asked = this.resolve(rel);
|
|
15619
|
+
let abs = asked;
|
|
15620
|
+
try {
|
|
15621
|
+
if (fs.lstatSync(asked).isSymbolicLink()) abs = fs.realpathSync(asked);
|
|
15622
|
+
} catch {}
|
|
15290
15623
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
15291
15624
|
const tmp = `${abs}.saywright-tmp`;
|
|
15292
15625
|
fs.writeFileSync(tmp, content);
|
|
15293
15626
|
fs.renameSync(tmp, abs);
|
|
15294
|
-
this.index?.forget(this.rel(
|
|
15627
|
+
this.index?.forget(this.rel(asked));
|
|
15628
|
+
if (abs !== asked) this.index?.forget(this.rel(abs));
|
|
15295
15629
|
}
|
|
15296
15630
|
remove(rel) {
|
|
15297
15631
|
const abs = this.resolve(rel);
|
|
@@ -16134,21 +16468,100 @@ function upstreamUrl(raw, upstream) {
|
|
|
16134
16468
|
return url.origin === upstream.origin ? url : null;
|
|
16135
16469
|
}
|
|
16136
16470
|
/** This computer's own names. */
|
|
16137
|
-
var LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$/;
|
|
16138
|
-
/**
|
|
16139
|
-
|
|
16471
|
+
var LOOPBACK$1 = /^(localhost|127\.0\.0\.1|\[::1\])$/;
|
|
16472
|
+
/**
|
|
16473
|
+
* The proxy's path for another port of this computer: /__saywright/<http|https>[+cred]/<host>:<port><path>. `+cred`:
|
|
16474
|
+
* the page sent the request with its credentials (fetch's credentials: 'include', an event stream's withCredentials).
|
|
16475
|
+
*/
|
|
16476
|
+
var VIA = /^\/__saywright\/(https?)(\+cred)?\/(localhost|127\.0\.0\.1|\[::1\]):(\d{1,5})(\/.*)?$/s;
|
|
16140
16477
|
/**
|
|
16141
16478
|
* The address on this computer that a request to the proxy's path for another port stands for (the site's API), or
|
|
16142
16479
|
* null: for a site on this computer only, on this computer's own names only, and never one of `refused` (the proxy's
|
|
16143
16480
|
* own ports, the studio's).
|
|
16144
16481
|
*/
|
|
16145
16482
|
function viaUrl(raw, upstream, refused) {
|
|
16483
|
+
return viaRequest(raw, upstream, refused)?.url ?? null;
|
|
16484
|
+
}
|
|
16485
|
+
/** The same, and whether the page sent it with its credentials (see VIA). */
|
|
16486
|
+
function viaRequest(raw, upstream, refused) {
|
|
16146
16487
|
const m = VIA.exec(raw ?? "");
|
|
16147
|
-
if (!m || !LOOPBACK.test(upstream.hostname)) return null;
|
|
16148
|
-
const port = Number(m[
|
|
16149
|
-
const rest = m[
|
|
16488
|
+
if (!m || !LOOPBACK$1.test(upstream.hostname)) return null;
|
|
16489
|
+
const port = Number(m[4]);
|
|
16490
|
+
const rest = m[5] ?? "/";
|
|
16150
16491
|
if (!port || port > 65535 || refused.has(port) || rest.startsWith("//") || rest.startsWith("/\\")) return null;
|
|
16151
|
-
return
|
|
16492
|
+
return {
|
|
16493
|
+
url: new URL(`${m[1]}://${m[3]}:${port}${rest}`),
|
|
16494
|
+
credentials: !!m[2]
|
|
16495
|
+
};
|
|
16496
|
+
}
|
|
16497
|
+
/** Request headers the browser sets itself, or a page may not: none of them asks the API's leave first. */
|
|
16498
|
+
var BROWSER_SET = /* @__PURE__ */ new Set([
|
|
16499
|
+
"accept-charset",
|
|
16500
|
+
"accept-encoding",
|
|
16501
|
+
"access-control-request-headers",
|
|
16502
|
+
"access-control-request-method",
|
|
16503
|
+
"connection",
|
|
16504
|
+
"content-length",
|
|
16505
|
+
"cookie",
|
|
16506
|
+
"cookie2",
|
|
16507
|
+
"date",
|
|
16508
|
+
"dnt",
|
|
16509
|
+
"expect",
|
|
16510
|
+
"host",
|
|
16511
|
+
"keep-alive",
|
|
16512
|
+
"origin",
|
|
16513
|
+
"referer",
|
|
16514
|
+
"set-cookie",
|
|
16515
|
+
"te",
|
|
16516
|
+
"trailer",
|
|
16517
|
+
"transfer-encoding",
|
|
16518
|
+
"upgrade",
|
|
16519
|
+
"via",
|
|
16520
|
+
"user-agent",
|
|
16521
|
+
"priority",
|
|
16522
|
+
"pragma",
|
|
16523
|
+
"cache-control",
|
|
16524
|
+
PROXY_CREDENTIALS_HEADER
|
|
16525
|
+
]);
|
|
16526
|
+
var SIMPLE_TYPE = /^(?:application\/x-www-form-urlencoded|multipart\/form-data|text\/plain)\s*(?:;|$)/i;
|
|
16527
|
+
/**
|
|
16528
|
+
* What a request the page made to another origin must have its API allow before it is sent (the browser's
|
|
16529
|
+
* preflight), as the browser decides: a method other than GET, HEAD or POST, or a header of the page's own beyond
|
|
16530
|
+
* the few any page may send. Null: a simple request, sent as it is.
|
|
16531
|
+
*/
|
|
16532
|
+
function preflightFor(method, headers) {
|
|
16533
|
+
const own = Object.keys(headers).filter((h) => !BROWSER_SET.has(h) && !h.startsWith("sec-") && !h.startsWith("proxy-")).filter((h) => ![
|
|
16534
|
+
"accept",
|
|
16535
|
+
"accept-language",
|
|
16536
|
+
"content-language",
|
|
16537
|
+
"content-type"
|
|
16538
|
+
].includes(h) || h === "content-type" && !SIMPLE_TYPE.test(String(headers[h]))).sort();
|
|
16539
|
+
return [
|
|
16540
|
+
"GET",
|
|
16541
|
+
"HEAD",
|
|
16542
|
+
"POST"
|
|
16543
|
+
].includes(method) && !own.length ? null : {
|
|
16544
|
+
method,
|
|
16545
|
+
headers: own
|
|
16546
|
+
};
|
|
16547
|
+
}
|
|
16548
|
+
/** Whether an API's answer may be read by a page of `origin`, by its CORS headers, as the browser decides. */
|
|
16549
|
+
function corsAllows(headers, origin, credentials) {
|
|
16550
|
+
const allow = headers["access-control-allow-origin"];
|
|
16551
|
+
if (allow === origin) return !credentials || headers["access-control-allow-credentials"] === "true";
|
|
16552
|
+
return allow === "*" && !credentials;
|
|
16553
|
+
}
|
|
16554
|
+
/** Whether an API's answer to a preflight lets the request through. */
|
|
16555
|
+
function preflightAllows(status, headers, want, origin, credentials) {
|
|
16556
|
+
if (status < 200 || status > 299 || !corsAllows(headers, origin, credentials)) return false;
|
|
16557
|
+
const list = (h) => String(h ?? "").split(",").map((x) => x.trim().toLowerCase()).filter(Boolean);
|
|
16558
|
+
const methods = list(headers["access-control-allow-methods"]);
|
|
16559
|
+
const allowed = list(headers["access-control-allow-headers"]);
|
|
16560
|
+
return ([
|
|
16561
|
+
"GET",
|
|
16562
|
+
"HEAD",
|
|
16563
|
+
"POST"
|
|
16564
|
+
].includes(want.method) || methods.includes(want.method.toLowerCase()) || !credentials && methods.includes("*")) && want.headers.every((h) => allowed.includes(h) || !credentials && allowed.includes("*") && h !== "authorization");
|
|
16152
16565
|
}
|
|
16153
16566
|
/**
|
|
16154
16567
|
* Whether the page the proxy shows made the request: the browser says so (Sec-Fetch-Site, and Origin when it sends
|
|
@@ -16328,39 +16741,87 @@ var AppProxy = class {
|
|
|
16328
16741
|
* fails rather than getting an error page.
|
|
16329
16742
|
*/
|
|
16330
16743
|
via(target, req, res, proxyOrigin) {
|
|
16331
|
-
const
|
|
16332
|
-
if (!
|
|
16744
|
+
const found = viaRequest(req.url, target.upstream, this.refused());
|
|
16745
|
+
if (!found || !fromPage(req.headers, proxyOrigin)) {
|
|
16333
16746
|
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
16334
16747
|
res.end("This proxy passes requests on to another port of this computer for the page it shows, and only for it.");
|
|
16335
16748
|
return;
|
|
16336
16749
|
}
|
|
16750
|
+
const { url } = found;
|
|
16751
|
+
const credentials = found.credentials || req.headers["x-saywright-credentials"] === "include";
|
|
16752
|
+
const origin = target.upstream.origin;
|
|
16337
16753
|
const headers = this.requestHeaders(req, target, proxyOrigin);
|
|
16338
16754
|
headers.host = url.host;
|
|
16755
|
+
delete headers[PROXY_CREDENTIALS_HEADER];
|
|
16756
|
+
headers.origin = origin;
|
|
16757
|
+
headers["sec-fetch-site"] = "same-site";
|
|
16758
|
+
if (!credentials) delete headers.cookie;
|
|
16339
16759
|
if (req.headers["accept-encoding"]) headers["accept-encoding"] = req.headers["accept-encoding"];
|
|
16340
16760
|
else delete headers["accept-encoding"];
|
|
16341
16761
|
const secure = url.protocol === "https:";
|
|
16342
|
-
const
|
|
16343
|
-
|
|
16344
|
-
|
|
16762
|
+
const lib = secure ? https : http;
|
|
16763
|
+
const tlsOptions = secure ? {
|
|
16764
|
+
rejectUnauthorized: false,
|
|
16765
|
+
servername: url.hostname === "localhost" ? "localhost" : void 0
|
|
16766
|
+
} : {};
|
|
16767
|
+
const blocked = (why) => {
|
|
16768
|
+
if (res.headersSent) return void res.destroy();
|
|
16769
|
+
res.writeHead(403, {
|
|
16770
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
16771
|
+
"X-Saywright-Blocked": "cors"
|
|
16772
|
+
});
|
|
16773
|
+
res.end(`Blocked as the browser blocks it outside the studio: ${why}`);
|
|
16774
|
+
};
|
|
16775
|
+
const opaque = req.headers["sec-fetch-mode"] === "no-cors";
|
|
16776
|
+
const send = () => {
|
|
16777
|
+
const upstreamReq = lib.request(url, {
|
|
16778
|
+
method: req.method,
|
|
16779
|
+
headers,
|
|
16780
|
+
timeout: 3e4,
|
|
16781
|
+
...tlsOptions
|
|
16782
|
+
}, (up) => {
|
|
16783
|
+
if (!opaque && !corsAllows(up.headers, origin, credentials)) {
|
|
16784
|
+
up.resume();
|
|
16785
|
+
return blocked(`${url.host} doesn’t let pages of ${origin} read what it answers${credentials ? " with their cookies" : ""} (CORS).`);
|
|
16786
|
+
}
|
|
16787
|
+
const out = {};
|
|
16788
|
+
for (const [k, v] of Object.entries(up.headers)) {
|
|
16789
|
+
if (v === void 0 || HOP.has(k) || DROP_RESPONSE.has(k)) continue;
|
|
16790
|
+
if (k === "set-cookie") out[k] = (Array.isArray(v) ? v : [v]).map((c) => rewriteSetCookie(c, target.port, viaPrefix(url)));
|
|
16791
|
+
else if (k === "location") out[k] = this.viaLocation(String(v), url, target, proxyOrigin);
|
|
16792
|
+
else out[k] = v;
|
|
16793
|
+
}
|
|
16794
|
+
res.writeHead(up.statusCode ?? 502, out);
|
|
16795
|
+
up.pipe(res);
|
|
16796
|
+
});
|
|
16797
|
+
upstreamReq.on("timeout", () => upstreamReq.destroy(/* @__PURE__ */ new Error("timed out")));
|
|
16798
|
+
upstreamReq.on("error", () => res.destroy());
|
|
16799
|
+
req.pipe(upstreamReq);
|
|
16800
|
+
};
|
|
16801
|
+
const want = opaque ? null : preflightFor(req.method ?? "GET", req.headers);
|
|
16802
|
+
if (!want) return send();
|
|
16803
|
+
const ask = {
|
|
16804
|
+
host: url.host,
|
|
16805
|
+
origin,
|
|
16806
|
+
"access-control-request-method": want.method,
|
|
16807
|
+
"sec-fetch-mode": "cors",
|
|
16808
|
+
"sec-fetch-site": "same-site"
|
|
16809
|
+
};
|
|
16810
|
+
if (want.headers.length) ask["access-control-request-headers"] = want.headers.join(",");
|
|
16811
|
+
if (req.headers["user-agent"]) ask["user-agent"] = req.headers["user-agent"];
|
|
16812
|
+
const pre = lib.request(url, {
|
|
16813
|
+
method: "OPTIONS",
|
|
16814
|
+
headers: ask,
|
|
16345
16815
|
timeout: 3e4,
|
|
16346
|
-
...
|
|
16347
|
-
rejectUnauthorized: false,
|
|
16348
|
-
servername: url.hostname === "localhost" ? "localhost" : void 0
|
|
16349
|
-
} : {}
|
|
16816
|
+
...tlsOptions
|
|
16350
16817
|
}, (up) => {
|
|
16351
|
-
|
|
16352
|
-
|
|
16353
|
-
|
|
16354
|
-
if (k === "set-cookie") out[k] = (Array.isArray(v) ? v : [v]).map((c) => rewriteSetCookie(c, target.port, viaPrefix(url)));
|
|
16355
|
-
else if (k === "location") out[k] = this.viaLocation(String(v), url, target, proxyOrigin);
|
|
16356
|
-
else out[k] = v;
|
|
16357
|
-
}
|
|
16358
|
-
res.writeHead(up.statusCode ?? 502, out);
|
|
16359
|
-
up.pipe(res);
|
|
16818
|
+
up.resume();
|
|
16819
|
+
if (preflightAllows(up.statusCode ?? 0, up.headers, want, origin, credentials)) send();
|
|
16820
|
+
else blocked(`${url.host} doesn’t allow a ${want.method} request${want.headers.length ? ` with ${want.headers.join(", ")}` : ""} from ${origin} (CORS).`);
|
|
16360
16821
|
});
|
|
16361
|
-
|
|
16362
|
-
|
|
16363
|
-
|
|
16822
|
+
pre.on("timeout", () => pre.destroy(/* @__PURE__ */ new Error("timed out")));
|
|
16823
|
+
pre.on("error", () => res.destroy());
|
|
16824
|
+
pre.end();
|
|
16364
16825
|
}
|
|
16365
16826
|
/** A redirect from the API: to itself, it stays on its path in the proxy; to the site, in the proxy; elsewhere, as sent. */
|
|
16366
16827
|
viaLocation(location, api, target, proxyOrigin) {
|
|
@@ -16690,7 +17151,8 @@ var AppManager = class {
|
|
|
16690
17151
|
let connection = latest.connection;
|
|
16691
17152
|
if (env.kind === "local") {
|
|
16692
17153
|
if (await pluginAnswers(url, app.root && hasPlugin(app.root) ? 2e4 : 5e3)) connection = "plugin";
|
|
16693
|
-
else if (
|
|
17154
|
+
else if (app.root && hasScriptTag(app.root)) connection = "script";
|
|
17155
|
+
else if (connection === "plugin" || connection === "script" && app.root) connection = "proxy";
|
|
16694
17156
|
if (connection !== latest.connection) this.registry.setEnv(app.id, {
|
|
16695
17157
|
...latest,
|
|
16696
17158
|
connection
|
|
@@ -16723,6 +17185,27 @@ var AppManager = class {
|
|
|
16723
17185
|
this.spaces.set(app.id, ws);
|
|
16724
17186
|
return ws;
|
|
16725
17187
|
}
|
|
17188
|
+
/**
|
|
17189
|
+
* Apps whose newest session was still recording when the studio stopped: their workspace is set up now (without
|
|
17190
|
+
* opening the app), so the page recording it finds the studio again and the recording goes on.
|
|
17191
|
+
*/
|
|
17192
|
+
restoreRecordings() {
|
|
17193
|
+
for (const app of this.registry.list()) {
|
|
17194
|
+
if (this.spaces.has(app.id)) continue;
|
|
17195
|
+
const state = app.root ? path.join(app.root, ".saywright") : path.join(this.deps.dataDir, "apps", app.id);
|
|
17196
|
+
if (!recordingIn(path.join(state, "sessions"))) continue;
|
|
17197
|
+
const env = app.envs.find((e) => e.id === app.env) ?? app.envs[0];
|
|
17198
|
+
const dataDir = appStateDir(this.deps.dataDir, app);
|
|
17199
|
+
const project = new Project(app.root ?? dataDir, env.url, dataDir, !!app.root);
|
|
17200
|
+
this.spaces.set(app.id, {
|
|
17201
|
+
app,
|
|
17202
|
+
env,
|
|
17203
|
+
project,
|
|
17204
|
+
frameUrl: env.url,
|
|
17205
|
+
...this.deps.build(project)
|
|
17206
|
+
});
|
|
17207
|
+
}
|
|
17208
|
+
}
|
|
16726
17209
|
/** Tell the open app's plugin where the studio is (once the studio knows its own address). */
|
|
16727
17210
|
announceActive() {
|
|
16728
17211
|
const ws = this.active();
|
|
@@ -16783,6 +17266,16 @@ var AppManager = class {
|
|
|
16783
17266
|
this.proxy.close();
|
|
16784
17267
|
}
|
|
16785
17268
|
};
|
|
17269
|
+
/** Whether the newest session in a sessions folder was still recording (only one records at a time, the newest). */
|
|
17270
|
+
function recordingIn(dir) {
|
|
17271
|
+
try {
|
|
17272
|
+
const newest = fs.readdirSync(dir).filter((n) => /^\d{8}-\d{6}-[a-z0-9]{4}$/.test(n)).sort().pop();
|
|
17273
|
+
if (!newest) return false;
|
|
17274
|
+
return JSON.parse(fs.readFileSync(path.join(dir, newest, "session.json"), "utf8")).status === "recording";
|
|
17275
|
+
} catch {
|
|
17276
|
+
return false;
|
|
17277
|
+
}
|
|
17278
|
+
}
|
|
16786
17279
|
//#endregion
|
|
16787
17280
|
//#region server/apps/github.ts
|
|
16788
17281
|
/**
|
|
@@ -17218,6 +17711,16 @@ var FakeProvider = class {
|
|
|
17218
17711
|
}])
|
|
17219
17712
|
};
|
|
17220
17713
|
}
|
|
17714
|
+
if (input.mode === "fix" && input.test && /\b(?:change|edit) the test\b/i.test(input.text)) return {
|
|
17715
|
+
kind: "change",
|
|
17716
|
+
title: "Loosens the check",
|
|
17717
|
+
summary: "The test now accepts any amount.",
|
|
17718
|
+
edits: applyEditSpecs(ctx.project, [{
|
|
17719
|
+
path: input.test.path,
|
|
17720
|
+
old_string: "toContainText('$80.80')",
|
|
17721
|
+
new_string: "toContainText('$')"
|
|
17722
|
+
}])
|
|
17723
|
+
};
|
|
17221
17724
|
if (input.mode === "fix" || /\bfix\b/.test(t)) {
|
|
17222
17725
|
ctx.progress("Reading", "src/lib/pricing.ts");
|
|
17223
17726
|
await sleep$1(150);
|
|
@@ -23505,11 +24008,11 @@ function numbered(lines, start, end) {
|
|
|
23505
24008
|
function userMessage(input, project) {
|
|
23506
24009
|
const parts = [];
|
|
23507
24010
|
parts.push(`Said: "${input.text.trim()}"`);
|
|
23508
|
-
if (input.pageUrl) parts.push(`Page: ${input.pageUrl}`);
|
|
24011
|
+
if (input.pageUrl) parts.push(`Page: ${redactUrl(input.pageUrl)}`);
|
|
23509
24012
|
if (input.refs.length) {
|
|
23510
24013
|
parts.push("\nReferenced elements:");
|
|
23511
24014
|
input.refs.forEach((r, i) => {
|
|
23512
|
-
const text = r.text ? ` showing “${r.text.slice(0, 60)}”` : "";
|
|
24015
|
+
const text = r.text ? ` showing “${maskSecrets(r.text).slice(0, 60)}”` : "";
|
|
23513
24016
|
parts.push(`${i + 1}. ${r.role} → ${r.name}${text}${r.src ? ` at ${r.src}${r.guessed ? " (a guess, found by searching the code for its text)" : ""}` : " (not mapped to source)"}`);
|
|
23514
24017
|
});
|
|
23515
24018
|
} else parts.push("\nThe user did not point at anything; work out the target from their words.");
|
|
@@ -42363,11 +42866,25 @@ var UsageLedger = class {
|
|
|
42363
42866
|
message: null
|
|
42364
42867
|
};
|
|
42365
42868
|
}
|
|
42366
|
-
/**
|
|
42367
|
-
|
|
42368
|
-
|
|
42869
|
+
/**
|
|
42870
|
+
* Whether a call may go ahead. In "stop" mode, not once a limit is reached, nor when the call itself (`extraUsd`,
|
|
42871
|
+
* `extraTokens`: what it is expected to cost) would go past one.
|
|
42872
|
+
*/
|
|
42873
|
+
allow(now = Date.now(), extraUsd = 0, extraTokens = 0) {
|
|
42874
|
+
const status = this.status(now, extraUsd, extraTokens);
|
|
42875
|
+
const stop = status.level === "over" && this.state.budget.mode === "stop";
|
|
42876
|
+
if (stop && (extraUsd > 0 || extraTokens > 0) && this.status(now).level !== "over") {
|
|
42877
|
+
const more = extraUsd > 0 ? `about ${money(extraUsd)} more` : `about ${compact(extraTokens)} more tokens`;
|
|
42878
|
+
return {
|
|
42879
|
+
ok: false,
|
|
42880
|
+
status: {
|
|
42881
|
+
...status,
|
|
42882
|
+
message: `${(status.message ?? "Near your budget.").replace(/\.$/, "")}, and this would take ${more}.`
|
|
42883
|
+
}
|
|
42884
|
+
};
|
|
42885
|
+
}
|
|
42369
42886
|
return {
|
|
42370
|
-
ok: !
|
|
42887
|
+
ok: !stop,
|
|
42371
42888
|
status
|
|
42372
42889
|
};
|
|
42373
42890
|
}
|
|
@@ -42457,9 +42974,9 @@ var AiService = class {
|
|
|
42457
42974
|
get effort() {
|
|
42458
42975
|
return this.connections.config.effort;
|
|
42459
42976
|
}
|
|
42460
|
-
/** Throws when the budget says stop. */
|
|
42461
|
-
gate() {
|
|
42462
|
-
const { ok, status } = this.usage.allow();
|
|
42977
|
+
/** Throws when the budget says stop (with `call`, what the call about to be made is expected to cost, counted in). */
|
|
42978
|
+
gate(call) {
|
|
42979
|
+
const { ok, status } = this.usage.allow(Date.now(), call?.usd ?? 0, call?.tokens ?? 0);
|
|
42463
42980
|
if (!ok) throw new AgentError(`${status.message ?? "The AI budget is used up."} Raise the limit or reset the count in Settings → AI to carry on.`, "budget");
|
|
42464
42981
|
}
|
|
42465
42982
|
record(c, model, u, feature, ms, reportedUsd) {
|
|
@@ -42549,10 +43066,22 @@ var AiService = class {
|
|
|
42549
43066
|
const chat = this.connections.chatModel(c.id, r.model);
|
|
42550
43067
|
if (!chat) throw new AgentError(`${c.label} can’t be used right now.`, "other");
|
|
42551
43068
|
const parts = chat.vision ? req.parts : req.parts.filter((p) => p.type === "text");
|
|
43069
|
+
const input = Math.ceil((req.system.length + parts.reduce((n, p) => n + (p.type === "text" ? p.text.length : 0), 0)) / 4) + parts.filter((p) => p.type === "image").length * 1600;
|
|
43070
|
+
const output = Math.min(req.maxTokens, 4e3);
|
|
43071
|
+
const price = (c.models.find((m) => m.id === r.model) ?? modelInfo(c.kind, r.model))?.price;
|
|
43072
|
+
const call = {
|
|
43073
|
+
usd: costOf(price, {
|
|
43074
|
+
input,
|
|
43075
|
+
output,
|
|
43076
|
+
cacheRead: 0,
|
|
43077
|
+
cacheWrite: 0
|
|
43078
|
+
}) ?? 0,
|
|
43079
|
+
tokens: input + output
|
|
43080
|
+
};
|
|
42552
43081
|
return jsonFrom(chat, {
|
|
42553
43082
|
...req,
|
|
42554
43083
|
parts
|
|
42555
|
-
}, () => this.gate(), (res, ms) => this.record(c, r.model, res.usage, req.feature, ms, res.usd));
|
|
43084
|
+
}, () => this.gate(call), (res, ms) => this.record(c, r.model, res.usage, req.feature, ms, res.usd));
|
|
42556
43085
|
}
|
|
42557
43086
|
/** A tiny real call, to check a connection works (a few tokens). */
|
|
42558
43087
|
async test(connectionId) {
|
|
@@ -44333,6 +44862,7 @@ function editorRoute(project) {
|
|
|
44333
44862
|
}
|
|
44334
44863
|
//#endregion
|
|
44335
44864
|
//#region shared/autopilot.ts
|
|
44865
|
+
/** Autopilot's steps, as the studio and its server both describe them (see server/autopilot.ts). */
|
|
44336
44866
|
var STEP_KINDS = [
|
|
44337
44867
|
"click",
|
|
44338
44868
|
"fill",
|
|
@@ -44370,7 +44900,10 @@ function describeStep(action, control) {
|
|
|
44370
44900
|
const name = control ? `“${control.name || control.role}”` : "";
|
|
44371
44901
|
switch (action.kind) {
|
|
44372
44902
|
case "click": return `Clicked ${name}`;
|
|
44373
|
-
case "fill":
|
|
44903
|
+
case "fill": {
|
|
44904
|
+
const value = action.value ?? "";
|
|
44905
|
+
return `Typed ${!!control?.field?.secret || isSecretHint(control?.name ?? "") || looksSecret(value) ? MASK : `“${maskSecrets(value)}”`} into ${name}`;
|
|
44906
|
+
}
|
|
44374
44907
|
case "select": return `Chose “${action.value ?? ""}” in ${name}`;
|
|
44375
44908
|
case "check": return `${action.value === "off" ? "Unticked" : "Ticked"} ${name}`;
|
|
44376
44909
|
case "press": return control ? `Pressed ${action.value || "Enter"} in ${name}` : `Pressed ${action.value || "Enter"}`;
|
|
@@ -46330,6 +46863,56 @@ function findElement(code, loc, parsed) {
|
|
|
46330
46863
|
});
|
|
46331
46864
|
return found;
|
|
46332
46865
|
}
|
|
46866
|
+
/** An element's own words as written: its text children, collapsed; empty when they are code (`{t('save')}`) or none. */
|
|
46867
|
+
function staticText(el) {
|
|
46868
|
+
let out = "";
|
|
46869
|
+
for (const c of el.children ?? []) if (c.type === "JSXText") out += ` ${c.value}`;
|
|
46870
|
+
else if (c.type === "JSXExpressionContainer" && c.expression?.type === "StringLiteral") out += ` ${c.expression.value}`;
|
|
46871
|
+
else if (c.type === "JSXExpressionContainer" && c.expression?.type !== "JSXEmptyExpression") return "";
|
|
46872
|
+
return out.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'|'/g, "'").replace(/\s+/g, " ").trim();
|
|
46873
|
+
}
|
|
46874
|
+
/** Whether the code's element can be the one that showed `text` (its own words are in it); true when it can't be told. */
|
|
46875
|
+
function showsText(el, text) {
|
|
46876
|
+
const own = staticText(el).toLowerCase();
|
|
46877
|
+
const shown = text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
46878
|
+
return !own || !shown || shown.includes(own);
|
|
46879
|
+
}
|
|
46880
|
+
/**
|
|
46881
|
+
* The element named `name` that shows `text`, near where one was (the code moved: lines added above it, or another
|
|
46882
|
+
* element now at its place), by its own words. `many` when several fit: never a guess between look-alikes.
|
|
46883
|
+
*/
|
|
46884
|
+
function findByWords(code, near, name, text, ast) {
|
|
46885
|
+
const tree = ast ?? parseTsx(code);
|
|
46886
|
+
if (!text.trim()) return null;
|
|
46887
|
+
const fits = [];
|
|
46888
|
+
const stack = [tree.program];
|
|
46889
|
+
while (stack.length) {
|
|
46890
|
+
const n = stack.pop();
|
|
46891
|
+
if (!n || typeof n !== "object") continue;
|
|
46892
|
+
if (Array.isArray(n)) {
|
|
46893
|
+
for (const c of n) stack.push(c);
|
|
46894
|
+
continue;
|
|
46895
|
+
}
|
|
46896
|
+
if (n.type === "JSXElement" && Math.abs(n.loc.start.line - near.line) <= 60 && elementName(n) === name && staticText(n) && showsText(n, text)) fits.push(n);
|
|
46897
|
+
for (const key of Object.keys(n)) {
|
|
46898
|
+
if (SKIP_KEYS$1.has(key)) continue;
|
|
46899
|
+
const v = n[key];
|
|
46900
|
+
if (v && typeof v === "object") stack.push(v);
|
|
46901
|
+
}
|
|
46902
|
+
}
|
|
46903
|
+
if (fits.length > 1) return "many";
|
|
46904
|
+
if (!fits.length) return null;
|
|
46905
|
+
const at = fits[0].openingElement.loc.start;
|
|
46906
|
+
const loc = {
|
|
46907
|
+
line: at.line,
|
|
46908
|
+
col: at.column + 1
|
|
46909
|
+
};
|
|
46910
|
+
const found = findElement(code, loc, tree);
|
|
46911
|
+
return found ? {
|
|
46912
|
+
loc,
|
|
46913
|
+
found
|
|
46914
|
+
} : null;
|
|
46915
|
+
}
|
|
46333
46916
|
function elementName(el) {
|
|
46334
46917
|
const name = (n) => n.type === "JSXIdentifier" ? n.name : n.type === "JSXMemberExpression" ? `${name(n.object)}.${name(n.property)}` : "";
|
|
46335
46918
|
return name(el.openingElement.name);
|
|
@@ -46497,18 +47080,240 @@ function lineRange(code, node) {
|
|
|
46497
47080
|
whole: false
|
|
46498
47081
|
};
|
|
46499
47082
|
}
|
|
46500
|
-
/**
|
|
46501
|
-
|
|
46502
|
-
* markup around it: these two need no parse to check.
|
|
46503
|
-
*/
|
|
46504
|
-
function removeElement(code, loc, ast) {
|
|
47083
|
+
/** What removing the element leaves: the code, and where the removed part began. */
|
|
47084
|
+
function removal$1(code, loc, ast) {
|
|
46505
47085
|
const f = findElement(code, loc, ast);
|
|
46506
47086
|
if (!f) throw new CodemodError("Element not found at that location");
|
|
46507
47087
|
const { unit } = childUnit(f, code);
|
|
46508
47088
|
const r = lineRange(code, unit);
|
|
46509
47089
|
const s = new MagicString(code);
|
|
46510
47090
|
s.remove(r.start, r.end);
|
|
46511
|
-
return
|
|
47091
|
+
return {
|
|
47092
|
+
code: s.toString(),
|
|
47093
|
+
at: r.start,
|
|
47094
|
+
found: f
|
|
47095
|
+
};
|
|
47096
|
+
}
|
|
47097
|
+
/** Names never taken out with what used them: the JSX of the classic runtime needs them in scope unwritten. */
|
|
47098
|
+
var KEEP_IMPORTS = /* @__PURE__ */ new Set(["React", "h"]);
|
|
47099
|
+
/**
|
|
47100
|
+
* Remove the element, and what only it used, so the app still builds (an unused import or prop fails a build that
|
|
47101
|
+
* checks for them): the imports, and the props its component read for it (unless the props are spread on, where
|
|
47102
|
+
* leaving one out would change what is passed). Anything else it alone used (a variable, a helper, a hook's value)
|
|
47103
|
+
* may do something besides, so it stays and is named in `unused`.
|
|
47104
|
+
*/
|
|
47105
|
+
function removeElementTidy(code, loc, ast) {
|
|
47106
|
+
const tree = ast ?? parseTsx(code);
|
|
47107
|
+
const { code: after, at, found } = removal$1(code, loc, tree);
|
|
47108
|
+
const before = nameCounts(tree.program);
|
|
47109
|
+
const took = [];
|
|
47110
|
+
/** Written somewhere besides its own declaration before the removal, and nowhere after it. */
|
|
47111
|
+
const gone = (now) => (name, own) => (before.get(name) ?? 0) > own && (now.get(name) ?? 0) <= own;
|
|
47112
|
+
let current = after;
|
|
47113
|
+
let currentTree;
|
|
47114
|
+
try {
|
|
47115
|
+
currentTree = parseTsx(after);
|
|
47116
|
+
} catch {
|
|
47117
|
+
return {
|
|
47118
|
+
code: after,
|
|
47119
|
+
took: [],
|
|
47120
|
+
unused: []
|
|
47121
|
+
};
|
|
47122
|
+
}
|
|
47123
|
+
const fns = functionsAround(tree, found.el.start);
|
|
47124
|
+
{
|
|
47125
|
+
const lastUseGone = gone(nameCounts(currentTree.program));
|
|
47126
|
+
const s = new MagicString(current);
|
|
47127
|
+
let changed = false;
|
|
47128
|
+
for (const fn of fns) {
|
|
47129
|
+
const pattern = fn.params?.[0];
|
|
47130
|
+
if (pattern?.type !== "ObjectPattern" || pattern.end > at || pattern.properties.some((p) => p.type === "RestElement")) continue;
|
|
47131
|
+
const drop = /* @__PURE__ */ new Set();
|
|
47132
|
+
for (const prop of pattern.properties) {
|
|
47133
|
+
const name = boundName(prop);
|
|
47134
|
+
if (name && lastUseGone(name, occurrences(prop, name))) drop.add(prop);
|
|
47135
|
+
}
|
|
47136
|
+
if (!drop.size) continue;
|
|
47137
|
+
for (const prop of drop) took.push(`the ${boundName(prop)} prop`);
|
|
47138
|
+
if (drop.size === pattern.properties.length) {
|
|
47139
|
+
const close = current.indexOf("}", pattern.properties[pattern.properties.length - 1].end);
|
|
47140
|
+
s.overwrite(pattern.start, close + 1, "_props");
|
|
47141
|
+
} else dropFromList(s, pattern.properties, drop);
|
|
47142
|
+
changed = true;
|
|
47143
|
+
}
|
|
47144
|
+
if (changed) {
|
|
47145
|
+
const next = s.toString();
|
|
47146
|
+
try {
|
|
47147
|
+
currentTree = parseTsx(next);
|
|
47148
|
+
current = next;
|
|
47149
|
+
} catch {
|
|
47150
|
+
took.length = 0;
|
|
47151
|
+
}
|
|
47152
|
+
}
|
|
47153
|
+
}
|
|
47154
|
+
{
|
|
47155
|
+
const lastUseGone = gone(nameCounts(currentTree.program));
|
|
47156
|
+
const s = new MagicString(current);
|
|
47157
|
+
const named = [];
|
|
47158
|
+
for (const decl of currentTree.program.body) {
|
|
47159
|
+
if (decl.type !== "ImportDeclaration" || !decl.specifiers.length) continue;
|
|
47160
|
+
const specs = decl.specifiers;
|
|
47161
|
+
const drop = specs.filter((sp) => !KEEP_IMPORTS.has(sp.local.name) && lastUseGone(sp.local.name, 0));
|
|
47162
|
+
if (!drop.length) continue;
|
|
47163
|
+
for (const sp of drop) named.push(`the ${sp.local.name} import`);
|
|
47164
|
+
if (drop.length === specs.length) {
|
|
47165
|
+
const r = lineRange(current, decl);
|
|
47166
|
+
s.remove(r.start, r.end);
|
|
47167
|
+
continue;
|
|
47168
|
+
}
|
|
47169
|
+
const def = specs.find((sp) => sp.type === "ImportDefaultSpecifier");
|
|
47170
|
+
const ns = specs.find((sp) => sp.type === "ImportNamespaceSpecifier");
|
|
47171
|
+
const list = specs.filter((sp) => sp.type === "ImportSpecifier");
|
|
47172
|
+
const out = new Set(drop);
|
|
47173
|
+
if (def && out.has(def)) s.remove(def.start, ns ? ns.start : current.indexOf("{", def.end));
|
|
47174
|
+
if (ns && out.has(ns)) s.remove(def ? def.end : ns.start, ns.end);
|
|
47175
|
+
if (list.length && list.every((sp) => out.has(sp))) {
|
|
47176
|
+
if (def) s.remove(def.end, current.indexOf("}", list[list.length - 1].end) + 1);
|
|
47177
|
+
} else dropFromList(s, list, out);
|
|
47178
|
+
}
|
|
47179
|
+
if (named.length) {
|
|
47180
|
+
const next = s.toString();
|
|
47181
|
+
try {
|
|
47182
|
+
currentTree = parseTsx(next);
|
|
47183
|
+
current = next;
|
|
47184
|
+
took.unshift(...named);
|
|
47185
|
+
} catch {}
|
|
47186
|
+
}
|
|
47187
|
+
}
|
|
47188
|
+
const lastUseGone = gone(nameCounts(currentTree.program));
|
|
47189
|
+
const unused = [];
|
|
47190
|
+
for (const fn of fns) for (const [name, own] of declaredIn(fn.body)) if (lastUseGone(name, own)) unused.push(name);
|
|
47191
|
+
for (const [name, own] of declaredIn(tree.program)) if (lastUseGone(name, own)) unused.push(name);
|
|
47192
|
+
return {
|
|
47193
|
+
code: current,
|
|
47194
|
+
took,
|
|
47195
|
+
unused
|
|
47196
|
+
};
|
|
47197
|
+
}
|
|
47198
|
+
/** Nodes whose `key` is a name written in place, not a use of one (`{ title: … }`, `title: string` in a type). */
|
|
47199
|
+
var NAMED_KEYS = /* @__PURE__ */ new Set([
|
|
47200
|
+
"ObjectProperty",
|
|
47201
|
+
"ObjectMethod",
|
|
47202
|
+
"ClassProperty",
|
|
47203
|
+
"ClassMethod",
|
|
47204
|
+
"ClassAccessorProperty",
|
|
47205
|
+
"TSPropertySignature",
|
|
47206
|
+
"TSMethodSignature"
|
|
47207
|
+
]);
|
|
47208
|
+
/**
|
|
47209
|
+
* How often each name is written outside imports, as a use or a binding: not where it only names a property
|
|
47210
|
+
* (`row.title`, `{ title: … }`, `<Field title=…>`, a type's `title: string`), which uses nothing by that name.
|
|
47211
|
+
*/
|
|
47212
|
+
function nameCounts(root, skip) {
|
|
47213
|
+
const counts = /* @__PURE__ */ new Map();
|
|
47214
|
+
const stack = [root];
|
|
47215
|
+
while (stack.length) {
|
|
47216
|
+
const n = stack.pop();
|
|
47217
|
+
if (!n || typeof n !== "object" || n === skip) continue;
|
|
47218
|
+
if (Array.isArray(n)) {
|
|
47219
|
+
for (const c of n) stack.push(c);
|
|
47220
|
+
continue;
|
|
47221
|
+
}
|
|
47222
|
+
if (n.type === "ImportDeclaration") continue;
|
|
47223
|
+
if ((n.type === "Identifier" || n.type === "JSXIdentifier") && typeof n.name === "string") counts.set(n.name, (counts.get(n.name) ?? 0) + 1);
|
|
47224
|
+
for (const key of Object.keys(n)) {
|
|
47225
|
+
if (SKIP_KEYS$1.has(key)) continue;
|
|
47226
|
+
if (key === "key" && NAMED_KEYS.has(n.type) && !n.computed) continue;
|
|
47227
|
+
if (key === "property" && (n.type === "MemberExpression" || n.type === "OptionalMemberExpression" || n.type === "JSXMemberExpression") && !n.computed) continue;
|
|
47228
|
+
if (key === "name" && n.type === "JSXAttribute") continue;
|
|
47229
|
+
if (key === "right" && n.type === "TSQualifiedName") continue;
|
|
47230
|
+
const v = n[key];
|
|
47231
|
+
if (v && typeof v === "object") stack.push(v);
|
|
47232
|
+
}
|
|
47233
|
+
}
|
|
47234
|
+
return counts;
|
|
47235
|
+
}
|
|
47236
|
+
/** How often `name` is written in a binding, its default value left out (that is a use of other names). */
|
|
47237
|
+
function occurrences(binding, name) {
|
|
47238
|
+
return nameCounts(binding, binding.value?.type === "AssignmentPattern" ? binding.value.right : binding.type === "AssignmentPattern" ? binding.right : void 0).get(name) ?? 0;
|
|
47239
|
+
}
|
|
47240
|
+
/** The name a property of a destructuring binds (`{ onDelete }`, `{ onDelete: del }`, `{ size = 'md' }`). */
|
|
47241
|
+
function boundName(prop) {
|
|
47242
|
+
if (prop.type !== "ObjectProperty" || prop.computed) return null;
|
|
47243
|
+
const v = prop.value;
|
|
47244
|
+
if (v?.type === "Identifier") return v.name;
|
|
47245
|
+
if (v?.type === "AssignmentPattern" && v.left.type === "Identifier") return v.left.name;
|
|
47246
|
+
return null;
|
|
47247
|
+
}
|
|
47248
|
+
/**
|
|
47249
|
+
* The component the element at `loc` is part of: the nearest function around it named like one (`function Card`,
|
|
47250
|
+
* `const Card = () => …`). Null outside one.
|
|
47251
|
+
*/
|
|
47252
|
+
function componentAt(code, loc, ast) {
|
|
47253
|
+
const tree = ast ?? parseTsx(code);
|
|
47254
|
+
const offset = offsetOf(code, loc);
|
|
47255
|
+
if (offset < 0) return null;
|
|
47256
|
+
const chain = [];
|
|
47257
|
+
walkAt(tree, null, offset, (n) => {
|
|
47258
|
+
chain.push(n);
|
|
47259
|
+
return false;
|
|
47260
|
+
});
|
|
47261
|
+
let name = null;
|
|
47262
|
+
chain.forEach((n, i) => {
|
|
47263
|
+
if (n.type !== "FunctionDeclaration" && n.type !== "FunctionExpression" && n.type !== "ArrowFunctionExpression") return;
|
|
47264
|
+
let j = i - 1;
|
|
47265
|
+
while (j >= 0 && chain[j].type === "CallExpression") j--;
|
|
47266
|
+
const own = (chain[j]?.type === "VariableDeclarator" && chain[j].id.type === "Identifier" ? chain[j].id.name : null) ?? n.id?.name ?? null;
|
|
47267
|
+
if (own && /^[A-Z]/.test(own)) name = own;
|
|
47268
|
+
});
|
|
47269
|
+
return name;
|
|
47270
|
+
}
|
|
47271
|
+
/** The functions around a place in the code, innermost first. */
|
|
47272
|
+
function functionsAround(ast, offset) {
|
|
47273
|
+
const out = [];
|
|
47274
|
+
walkAt(ast, null, offset, (n) => {
|
|
47275
|
+
if (n.type === "FunctionDeclaration" || n.type === "FunctionExpression" || n.type === "ArrowFunctionExpression") out.unshift(n);
|
|
47276
|
+
return false;
|
|
47277
|
+
});
|
|
47278
|
+
return out;
|
|
47279
|
+
}
|
|
47280
|
+
/** Names a block declares directly (not exported), with how often each is written in its declaration. */
|
|
47281
|
+
function declaredIn(block) {
|
|
47282
|
+
const out = [];
|
|
47283
|
+
const statements = block?.type === "BlockStatement" || block?.type === "Program" ? block.body : [];
|
|
47284
|
+
for (const st of statements) {
|
|
47285
|
+
if (st.type === "FunctionDeclaration" && st.id) out.push([st.id.name, 1]);
|
|
47286
|
+
if (st.type !== "VariableDeclaration") continue;
|
|
47287
|
+
for (const d of st.declarations) {
|
|
47288
|
+
const names = [];
|
|
47289
|
+
const collect = (p) => {
|
|
47290
|
+
if (!p) return;
|
|
47291
|
+
if (p.type === "Identifier") names.push(p.name);
|
|
47292
|
+
else if (p.type === "ObjectPattern") for (const prop of p.properties) collect(prop.type === "RestElement" ? prop.argument : prop.value);
|
|
47293
|
+
else if (p.type === "ArrayPattern") for (const e of p.elements) collect(e);
|
|
47294
|
+
else if (p.type === "AssignmentPattern") collect(p.left);
|
|
47295
|
+
};
|
|
47296
|
+
collect(d.id);
|
|
47297
|
+
for (const name of names) out.push([name, nameCounts(d.id, void 0).get(name) ?? 1]);
|
|
47298
|
+
}
|
|
47299
|
+
}
|
|
47300
|
+
return out;
|
|
47301
|
+
}
|
|
47302
|
+
/** Take `drop` out of a comma-separated list, each run of them with the comma joining it to what stays. */
|
|
47303
|
+
function dropFromList(s, items, drop) {
|
|
47304
|
+
let i = 0;
|
|
47305
|
+
while (i < items.length) {
|
|
47306
|
+
if (!drop.has(items[i])) {
|
|
47307
|
+
i++;
|
|
47308
|
+
continue;
|
|
47309
|
+
}
|
|
47310
|
+
let j = i;
|
|
47311
|
+
while (j + 1 < items.length && drop.has(items[j + 1])) j++;
|
|
47312
|
+
if (j + 1 < items.length) s.remove(items[i].start, items[j + 1].start);
|
|
47313
|
+
else if (i > 0) s.remove(items[i - 1].end, items[j].end);
|
|
47314
|
+
else s.remove(items[i].start, items[j].end);
|
|
47315
|
+
i = j + 1;
|
|
47316
|
+
}
|
|
46512
47317
|
}
|
|
46513
47318
|
function moveElement(code, from, to, where, parsed) {
|
|
46514
47319
|
const ast = parsed ?? parseTsx(code);
|
|
@@ -47605,6 +48410,7 @@ function sanitizeRefs(raw) {
|
|
|
47605
48410
|
if (hint) ref.hint = hint;
|
|
47606
48411
|
const frames = frameList(o.frames);
|
|
47607
48412
|
if (frames.length) ref.frames = frames;
|
|
48413
|
+
if (typeof o.same === "number" && Number.isInteger(o.same) && o.same > 1) ref.same = Math.min(o.same, 1e6);
|
|
47608
48414
|
return ref;
|
|
47609
48415
|
});
|
|
47610
48416
|
}
|
|
@@ -47639,7 +48445,7 @@ function sample(into, v, positive = false) {
|
|
|
47639
48445
|
if (into.length > SAMPLES) into.shift();
|
|
47640
48446
|
}
|
|
47641
48447
|
/**
|
|
47642
|
-
* How the studio is used in an app: the commands given (spoken or typed,
|
|
48448
|
+
* How the studio is used in an app: the commands given (spoken or typed, and whether the mouse pointed), the
|
|
47643
48449
|
* changes kept and undone, the tests run. Each event is appended to .saywright/metrics.jsonl in the app (exported
|
|
47644
48450
|
* from Stats); the studio itself keeps only the counts and the latest values its Stats show.
|
|
47645
48451
|
*/
|
|
@@ -47753,9 +48559,25 @@ var EVERYWHERE = /\b(?:everywhere|wherever|throughout|all of (?:them|these|those
|
|
|
47753
48559
|
var EVERYWHERE_AT_END = new RegExp(`\\s+(?:${EVERYWHERE.source})(?:\\s+(?:it|they)\\s+(?:appears?|shows?|is|are)(?:\\s+used)?)?[.!]*$`, "i");
|
|
47754
48560
|
/** What may follow a quoted new text without asking for anything more. */
|
|
47755
48561
|
var POLITE = /^(?:please|thanks|thank you|instead|now|for me)?[.!]*$/i;
|
|
47756
|
-
var MARK = /\uE000(\d+)\uE000/g;
|
|
48562
|
+
var MARK$1 = /\uE000(\d+)\uE000/g;
|
|
48563
|
+
/** An unquoted new text without the quotes and the full stop around it ("Are you sure?" keeps its question mark). */
|
|
47757
48564
|
function stripQuotes(s) {
|
|
47758
|
-
return s.replace(/^[\s"“'‘]+|[\s"”'
|
|
48565
|
+
return s.replace(/^[\s"“'‘]+|[\s"”'’.]+$/g, "");
|
|
48566
|
+
}
|
|
48567
|
+
/** Words that make a removal about something of the element, or about when, rather than the element itself. */
|
|
48568
|
+
var NOT_THE_ELEMENT = new Set("on in at for when if unless until while after before and but or so then with without except only instead to into than border borders shadow shadows padding margin margins spacing space gap underline background bg color colour outline radius rounded rounding corner corners bold italic animation transition scrollbar placeholder tooltip focus hover opacity blur gradient style styles styling class classes wrap wrapping overflow limit validation confirmation delay duplicate duplicates uppercase lowercase".split(" "));
|
|
48569
|
+
/**
|
|
48570
|
+
* What a request that starts with remove, delete, drop or hide asks to go: the element itself ("remove this", "get
|
|
48571
|
+
* rid of that button", "delete the VIP badge"), or something of it ("remove the border", "hide this on mobile").
|
|
48572
|
+
* Null for any other request: a word like delete in "make the Delete button ask first" is no reason to delete anything.
|
|
48573
|
+
*/
|
|
48574
|
+
function removal(asked) {
|
|
48575
|
+
const s = asked.trim().replace(/^(?:(?:please|can you|could you|would you|just|now)\s+)+/, "");
|
|
48576
|
+
const verb = /^(?:remove|delete|get rid of|drop|hide)\b(.*)$/.exec(s);
|
|
48577
|
+
if (!verb) return null;
|
|
48578
|
+
const rest = verb[1].replace(/\b(?:please|now|entirely|completely|altogether|for me)\b/g, " ").replace(/\bfrom (?:the |this )?(?:page|screen|view|app)\b/g, " ").trim();
|
|
48579
|
+
const words = rest ? rest.split(/\s+/) : [];
|
|
48580
|
+
return words.length <= 4 && !words.some((w) => NOT_THE_ELEMENT.has(w)) ? "element" : "other";
|
|
47759
48581
|
}
|
|
47760
48582
|
/**
|
|
47761
48583
|
* The quoted parts of a request ("…", “…”, «…», '…'), each replaced by a marker so the words around them are read
|
|
@@ -47772,56 +48594,73 @@ function quoted(s) {
|
|
|
47772
48594
|
values
|
|
47773
48595
|
};
|
|
47774
48596
|
}
|
|
47775
|
-
/**
|
|
48597
|
+
/**
|
|
48598
|
+
* The new text a rename asks for, whether it is for every place it appears, and whether it was quoted (then it is
|
|
48599
|
+
* taken exactly as written); null when more is asked of it than a rename.
|
|
48600
|
+
*/
|
|
47776
48601
|
function newText(tail, values) {
|
|
47777
48602
|
const lead = /^\s*\uE000(\d+)\uE000(.*)$/s.exec(tail);
|
|
47778
48603
|
if (lead) {
|
|
47779
48604
|
const value = values[Number(lead[1])].trim();
|
|
47780
|
-
const rest = lead[2].replace(MARK, (_m, i) => values[Number(i)]).trim();
|
|
48605
|
+
const rest = lead[2].replace(MARK$1, (_m, i) => values[Number(i)]).trim();
|
|
47781
48606
|
if (EVERYWHERE.test(rest)) return {
|
|
47782
48607
|
value,
|
|
47783
|
-
everywhere: true
|
|
48608
|
+
everywhere: true,
|
|
48609
|
+
quoted: true
|
|
47784
48610
|
};
|
|
47785
48611
|
return POLITE.test(rest) ? {
|
|
47786
48612
|
value,
|
|
47787
|
-
everywhere: false
|
|
48613
|
+
everywhere: false,
|
|
48614
|
+
quoted: true
|
|
47788
48615
|
} : null;
|
|
47789
48616
|
}
|
|
47790
|
-
let value = stripQuotes(tail.replace(MARK, (_m, i) => `"${values[Number(i)]}"`));
|
|
48617
|
+
let value = stripQuotes(tail.replace(MARK$1, (_m, i) => `"${values[Number(i)]}"`));
|
|
47791
48618
|
const scope = EVERYWHERE_AT_END.exec(value);
|
|
47792
48619
|
if (scope) value = value.slice(0, scope.index).trim();
|
|
47793
48620
|
return {
|
|
47794
48621
|
value,
|
|
47795
|
-
everywhere: !!scope
|
|
48622
|
+
everywhere: !!scope,
|
|
48623
|
+
quoted: false
|
|
47796
48624
|
};
|
|
47797
48625
|
}
|
|
48626
|
+
/**
|
|
48627
|
+
* An unquoted new text starts with a capital, as said ("rename this to pay now" \u2192 "Pay now"), unless its first word
|
|
48628
|
+
* is written with capitals of its own: "iOS", "eBay", "macOS" stay as they are.
|
|
48629
|
+
*/
|
|
48630
|
+
function capitalized(value) {
|
|
48631
|
+
const first = value.split(/\s/)[0];
|
|
48632
|
+
return /\p{Lu}|\d/u.test(first.slice(1)) ? value : value[0].toUpperCase() + value.slice(1);
|
|
48633
|
+
}
|
|
47798
48634
|
var words = (s) => " " + s.toLowerCase().replace(/[“”"‘’']/g, "").replace(/[^a-z0-9\s-]/g, " ").replace(/\s+/g, " ").trim() + " ";
|
|
47799
48635
|
function parseQuick(input) {
|
|
47800
48636
|
const raw = input.trim();
|
|
47801
48637
|
const { masked, values } = quoted(raw);
|
|
47802
48638
|
const t = words(raw);
|
|
47803
|
-
const asked = words(masked.replace(MARK, " "));
|
|
48639
|
+
const asked = words(masked.replace(MARK$1, " "));
|
|
47804
48640
|
if (/\b(look|looks|style|styled)\s+(just\s+|exactly\s+)?like\b|\bsame (style|look|size|styling) as\b|\bmatch(es)?\b/.test(asked)) return {
|
|
47805
48641
|
kind: "copy",
|
|
47806
48642
|
adjust: /\b(smaller|tinier|less)\b/.test(asked) ? -1 : /\b(bigger|larger)\b/.test(asked) ? 1 : 0
|
|
47807
48643
|
};
|
|
47808
|
-
const move = /\b(move|put|place|drag)\b.*\b(above|below|before|after|under|over|beneath|underneath)\b/.exec(asked);
|
|
47809
|
-
if (move) return {
|
|
47810
|
-
kind: "move",
|
|
47811
|
-
where: /^(below|after|under|beneath|underneath)$/.test(move[2]) ? "below" : "above"
|
|
47812
|
-
};
|
|
47813
|
-
if (/\b(remove|delete|get rid of|drop|hide)\b/.test(asked)) return { kind: "remove" };
|
|
47814
48644
|
const text = /\b(?:rename|relabel|call)\b.*?\b(?:to|as)\b\s+(.+)$/is.exec(masked) ?? /\b(?:change|set|update|make)\s+(?:the\s+)?(?:text|label|copy|title|wording|words?)\b.*?\b(?:to|into)\b\s+(.+)$/is.exec(masked) ?? /\b(?:make\s+(?:this|that|it)\s+)?(?:say|read)s?\s+(.+)$/is.exec(masked);
|
|
47815
48645
|
if (text) {
|
|
47816
48646
|
const found = newText(text[1], values);
|
|
47817
48647
|
if (!found) return null;
|
|
47818
|
-
const everywhere = found.everywhere || EVERYWHERE.test(masked.slice(0, text.index + text[0].length - text[1].length).replace(MARK, " "));
|
|
48648
|
+
const everywhere = found.everywhere || EVERYWHERE.test(masked.slice(0, text.index + text[0].length - text[1].length).replace(MARK$1, " "));
|
|
47819
48649
|
if (found.value) return {
|
|
47820
48650
|
kind: "text",
|
|
47821
|
-
value: found.value
|
|
48651
|
+
value: found.quoted ? found.value : capitalized(found.value),
|
|
47822
48652
|
...everywhere ? { everywhere: true } : {}
|
|
47823
48653
|
};
|
|
47824
48654
|
}
|
|
48655
|
+
if (/\b(?:and|then|also|plus)\b/.test(asked)) return null;
|
|
48656
|
+
const move = /\b(move|put|place|drag)\b.*\b(above|below|before|after|under|over|beneath|underneath)\b/.exec(asked);
|
|
48657
|
+
if (move) return {
|
|
48658
|
+
kind: "move",
|
|
48659
|
+
where: /^(below|after|under|beneath|underneath)$/.test(move[2]) ? "below" : "above"
|
|
48660
|
+
};
|
|
48661
|
+
const removes = removal(asked);
|
|
48662
|
+
if (removes === "element") return { kind: "remove" };
|
|
48663
|
+
if (removes === "other") return null;
|
|
47825
48664
|
const big = /\b(bigger|larger|huge|grow|taller|wider|increase)\b/.test(t);
|
|
47826
48665
|
const small = /\b(smaller|tiny|tinier|shrink|shorter|narrower|decrease|compact)\b/.test(t);
|
|
47827
48666
|
const spacing = /\b(padding|spacing|space|gap|gaps|room|airy|tighter|looser|roomier)\b/.test(t);
|
|
@@ -47898,144 +48737,195 @@ function quickEdit(text, refs, project) {
|
|
|
47898
48737
|
why: "Changing words everywhere they appear needs an AI: a quick edit changes only what you point at."
|
|
47899
48738
|
};
|
|
47900
48739
|
const a = refs[0];
|
|
47901
|
-
|
|
48740
|
+
if (!a && /\b(?:this|that|these|those|it|them)\b/i.test(text)) return {
|
|
48741
|
+
kind: "message",
|
|
48742
|
+
tone: "info",
|
|
48743
|
+
text: "Point at what you mean (or click it to select it), then say it again."
|
|
48744
|
+
};
|
|
48745
|
+
let where = locOf(a);
|
|
47902
48746
|
if (!a || !where || !/\.[jt]sx$/.test(where.file)) return null;
|
|
47903
48747
|
let code;
|
|
47904
48748
|
let ast;
|
|
47905
48749
|
try {
|
|
47906
48750
|
code = project.read(where.file);
|
|
47907
|
-
ast = parseTsx(code);
|
|
47908
48751
|
} catch {
|
|
47909
48752
|
return null;
|
|
47910
48753
|
}
|
|
47911
|
-
|
|
47912
|
-
|
|
47913
|
-
|
|
48754
|
+
try {
|
|
48755
|
+
ast = parseTsx(code);
|
|
48756
|
+
} catch {
|
|
48757
|
+
return {
|
|
48758
|
+
kind: "message",
|
|
48759
|
+
tone: "warn",
|
|
48760
|
+
text: `${where.file} doesn’t parse right now (a syntax error?), so a quick edit can’t be made in it. Fix it, or ask the AI.`,
|
|
48761
|
+
next: [{ kind: "ask-ai" }, {
|
|
48762
|
+
kind: "open",
|
|
48763
|
+
src: a.src ?? `${where.file}:1:1`
|
|
48764
|
+
}]
|
|
48765
|
+
};
|
|
48766
|
+
}
|
|
48767
|
+
let found = findElement(code, where.loc, ast);
|
|
48768
|
+
if (!found || !showsText(found.el, a.text)) {
|
|
48769
|
+
const moved = findByWords(code, where.loc, found ? elementName(found.el) : a.name, a.text, ast);
|
|
48770
|
+
if (moved && moved !== "many") {
|
|
48771
|
+
where = {
|
|
48772
|
+
file: where.file,
|
|
48773
|
+
loc: moved.loc
|
|
48774
|
+
};
|
|
48775
|
+
found = moved.found;
|
|
48776
|
+
} else if (!found || moved === "many") return {
|
|
48777
|
+
kind: "message",
|
|
48778
|
+
tone: "info",
|
|
48779
|
+
text: `The code changed since you selected ${describeRef(a)}, so it isn’t where it was: select it again.`,
|
|
48780
|
+
next: [{ kind: "ask-ai" }]
|
|
48781
|
+
};
|
|
48782
|
+
}
|
|
48783
|
+
const el = found.el;
|
|
48784
|
+
const here = where;
|
|
48785
|
+
const name = elementName(el);
|
|
47914
48786
|
const config = project.config;
|
|
47915
48787
|
const spec = project.componentSpec(name, where.file);
|
|
47916
|
-
const props = readProps(
|
|
48788
|
+
const props = readProps(el);
|
|
47917
48789
|
const what = describeRef(a);
|
|
47918
|
-
|
|
47919
|
-
|
|
47920
|
-
|
|
47921
|
-
|
|
47922
|
-
|
|
47923
|
-
|
|
47924
|
-
|
|
47925
|
-
|
|
47926
|
-
|
|
47927
|
-
|
|
47928
|
-
|
|
47929
|
-
|
|
47930
|
-
|
|
47931
|
-
|
|
47932
|
-
|
|
47933
|
-
|
|
47934
|
-
|
|
47935
|
-
|
|
47936
|
-
|
|
47937
|
-
if (!chosen) return null;
|
|
47938
|
-
const [prop, def] = chosen;
|
|
47939
|
-
const now = current(props, prop, def);
|
|
47940
|
-
if (now === null) return null;
|
|
47941
|
-
const steps = def.ladder ?? def.values;
|
|
47942
|
-
const i = steps.indexOf(now);
|
|
47943
|
-
if (i < 0) return null;
|
|
47944
|
-
const next = steps[Math.max(0, Math.min(steps.length - 1, i + intent.dir * intent.amount))];
|
|
47945
|
-
if (next === now) {
|
|
47946
|
-
const ds = spec.file ? [{
|
|
47947
|
-
kind: "open",
|
|
47948
|
-
src: `${spec.file}:1:1`
|
|
47949
|
-
}] : [];
|
|
47950
|
-
return {
|
|
48790
|
+
const result = attempt(intent, here);
|
|
48791
|
+
if (result?.kind === "edit") {
|
|
48792
|
+
if (a.same && a.same > 1) {
|
|
48793
|
+
result.title += ` (all ${a.same} on this page)`;
|
|
48794
|
+
result.summary += ` Its code draws it ${a.same} times on this page (the rows of a list, or a component used several times): this changes them all.`;
|
|
48795
|
+
}
|
|
48796
|
+
const component = componentAt(code, here.loc, ast);
|
|
48797
|
+
if (component) result.component = component;
|
|
48798
|
+
}
|
|
48799
|
+
return result;
|
|
48800
|
+
function attempt(intent, where) {
|
|
48801
|
+
try {
|
|
48802
|
+
switch (intent.kind) {
|
|
48803
|
+
case "set": {
|
|
48804
|
+
if (!spec) return null;
|
|
48805
|
+
const pick = pickSet(spec, intent.terms, config.synonyms);
|
|
48806
|
+
if (!pick) return null;
|
|
48807
|
+
const def = spec.props[pick.prop];
|
|
48808
|
+
if (current(props, pick.prop, def) === pick.value) return {
|
|
47951
48809
|
kind: "message",
|
|
47952
48810
|
tone: "info",
|
|
47953
|
-
text: `${what}
|
|
47954
|
-
next: [{ kind: "ask-ai" }, ...ds]
|
|
48811
|
+
text: `${what} already uses ${pick.prop} “${pick.value}”.`
|
|
47955
48812
|
};
|
|
48813
|
+
const after = setProp(code, where.loc, pick.prop, valueOrNull(def, pick.value), ast);
|
|
48814
|
+
const was = current(props, pick.prop, def);
|
|
48815
|
+
return edit(`${what} · ${pick.prop} ${was ? `${was} → ` : "→ "}${pick.value}`, `Set ${pick.prop}="${pick.value}" on ${name} in ${where.file}.`, where.file, code, after);
|
|
47956
48816
|
}
|
|
47957
|
-
|
|
47958
|
-
|
|
47959
|
-
|
|
47960
|
-
|
|
47961
|
-
|
|
47962
|
-
|
|
47963
|
-
|
|
47964
|
-
|
|
47965
|
-
|
|
47966
|
-
|
|
47967
|
-
|
|
47968
|
-
|
|
47969
|
-
|
|
47970
|
-
|
|
47971
|
-
|
|
47972
|
-
|
|
47973
|
-
|
|
47974
|
-
|
|
47975
|
-
|
|
47976
|
-
|
|
47977
|
-
|
|
47978
|
-
|
|
47979
|
-
if (intent.adjust && def.ordered) {
|
|
47980
|
-
const steps = def.ladder ?? def.values;
|
|
47981
|
-
const i = steps.indexOf(target);
|
|
47982
|
-
if (i >= 0) target = steps[Math.max(0, Math.min(steps.length - 1, i + intent.adjust))];
|
|
48817
|
+
case "step": {
|
|
48818
|
+
if (!spec) return null;
|
|
48819
|
+
const chosen = stepProp(spec, intent.focus);
|
|
48820
|
+
if (!chosen) return null;
|
|
48821
|
+
const [prop, def] = chosen;
|
|
48822
|
+
const now = current(props, prop, def);
|
|
48823
|
+
if (now === null) return null;
|
|
48824
|
+
const steps = def.ladder ?? def.values;
|
|
48825
|
+
const i = steps.indexOf(now);
|
|
48826
|
+
if (i < 0) return null;
|
|
48827
|
+
const next = steps[Math.max(0, Math.min(steps.length - 1, i + intent.dir * intent.amount))];
|
|
48828
|
+
if (next === now) {
|
|
48829
|
+
const ds = spec.file ? [{
|
|
48830
|
+
kind: "open",
|
|
48831
|
+
src: `${spec.file}:1:1`
|
|
48832
|
+
}] : [];
|
|
48833
|
+
return {
|
|
48834
|
+
kind: "message",
|
|
48835
|
+
tone: "info",
|
|
48836
|
+
text: `${what} is already the ${intent.dir > 0 ? "largest" : "smallest"} ${prop} (“${now}”) in your design system.`,
|
|
48837
|
+
next: [{ kind: "ask-ai" }, ...ds]
|
|
48838
|
+
};
|
|
47983
48839
|
}
|
|
47984
|
-
|
|
47985
|
-
|
|
47986
|
-
changed.push(`${prop}="${target}"`);
|
|
48840
|
+
const after = setProp(code, where.loc, prop, valueOrNull(def, next), ast);
|
|
48841
|
+
return edit(`${what} · ${prop} ${now} → ${next}`, `Changed ${prop} from "${now}" to "${next}" on ${name}.`, where.file, code, after);
|
|
47987
48842
|
}
|
|
47988
|
-
|
|
47989
|
-
|
|
47990
|
-
|
|
47991
|
-
|
|
47992
|
-
|
|
47993
|
-
|
|
47994
|
-
|
|
47995
|
-
return edit(`${what} looks like ${describeRef(b)}${tail}`, `Copied ${changed.join(", ")} from ${describeRef(b)}.`, where.file, code, after);
|
|
47996
|
-
}
|
|
47997
|
-
case "text": {
|
|
47998
|
-
const textProp = spec?.text ?? "children";
|
|
47999
|
-
let after;
|
|
48000
|
-
try {
|
|
48001
|
-
after = setText(code, where.loc, intent.value, textProp, ast);
|
|
48002
|
-
} catch (err) {
|
|
48003
|
-
if (!(err instanceof ComputedTextError)) throw err;
|
|
48004
|
-
return {
|
|
48005
|
-
kind: "needs-ai",
|
|
48006
|
-
why: `${what}’s text is built from code (“${childrenCode(code, found.el)}”), so changing it needs an AI.`,
|
|
48007
|
-
src: a.src ?? void 0
|
|
48843
|
+
case "copy": {
|
|
48844
|
+
const b = refs[1];
|
|
48845
|
+
const bWhere = locOf(b);
|
|
48846
|
+
if (!b || !bWhere) return {
|
|
48847
|
+
kind: "message",
|
|
48848
|
+
tone: "info",
|
|
48849
|
+
text: "Point at a second thing for “that”, or select both first (⇧ click each)."
|
|
48008
48850
|
};
|
|
48851
|
+
if (!spec) return null;
|
|
48852
|
+
const bCode = bWhere.file === where.file ? code : project.read(bWhere.file);
|
|
48853
|
+
const bFound = findElement(bCode, bWhere.loc, bCode === code ? ast : void 0);
|
|
48854
|
+
if (!bFound) return null;
|
|
48855
|
+
if (elementName(bFound.el) !== name) return null;
|
|
48856
|
+
const bProps = readProps(bFound.el);
|
|
48857
|
+
const sets = [];
|
|
48858
|
+
const changed = [];
|
|
48859
|
+
for (const [prop, def] of Object.entries(spec.props)) {
|
|
48860
|
+
let target = current(bProps, prop, def);
|
|
48861
|
+
if (target === null) continue;
|
|
48862
|
+
if (intent.adjust && def.ordered) {
|
|
48863
|
+
const steps = def.ladder ?? def.values;
|
|
48864
|
+
const i = steps.indexOf(target);
|
|
48865
|
+
if (i >= 0) target = steps[Math.max(0, Math.min(steps.length - 1, i + intent.adjust))];
|
|
48866
|
+
}
|
|
48867
|
+
if (current(props, prop, def) === target) continue;
|
|
48868
|
+
sets.push([prop, valueOrNull(def, target)]);
|
|
48869
|
+
changed.push(`${prop}="${target}"`);
|
|
48870
|
+
}
|
|
48871
|
+
if (!changed.length) return {
|
|
48872
|
+
kind: "message",
|
|
48873
|
+
tone: "info",
|
|
48874
|
+
text: `${what} already looks like ${describeRef(b)}.`
|
|
48875
|
+
};
|
|
48876
|
+
const after = setProps(code, where.loc, sets, ast);
|
|
48877
|
+
const tail = intent.adjust < 0 ? ", but smaller" : intent.adjust > 0 ? ", but bigger" : "";
|
|
48878
|
+
return edit(`${what} looks like ${describeRef(b)}${tail}`, `Copied ${changed.join(", ")} from ${describeRef(b)}.`, where.file, code, after);
|
|
48879
|
+
}
|
|
48880
|
+
case "text": {
|
|
48881
|
+
const textProp = spec?.text ?? "children";
|
|
48882
|
+
let after;
|
|
48883
|
+
try {
|
|
48884
|
+
after = setText(code, where.loc, intent.value, textProp, ast);
|
|
48885
|
+
} catch (err) {
|
|
48886
|
+
if (!(err instanceof ComputedTextError)) throw err;
|
|
48887
|
+
return {
|
|
48888
|
+
kind: "needs-ai",
|
|
48889
|
+
why: `${what}’s text is built from code (“${childrenCode(code, el)}”), so changing it needs an AI.`,
|
|
48890
|
+
src: a.src ?? void 0
|
|
48891
|
+
};
|
|
48892
|
+
}
|
|
48893
|
+
return edit(`Rename ${what} to “${intent.value}”`, `Changed the text of ${name} in ${where.file}.`, where.file, code, after);
|
|
48894
|
+
}
|
|
48895
|
+
case "remove": {
|
|
48896
|
+
const { code: after, took, unused } = removeElementTidy(code, where.loc, ast);
|
|
48897
|
+
const also = took.length ? ` Also took out what only it used: ${andList(took)}.` : "";
|
|
48898
|
+
const left = unused.length ? ` Now unused, and left as they are in case they do more: ${andList(unused.map((n) => `\`${n}\``))}.` : "";
|
|
48899
|
+
return edit(`Remove ${what}${unused.length ? ` · leaves ${unused.slice(0, 2).join(", ")}${unused.length > 2 ? "…" : ""} unused` : ""}`, `Removed the ${name} element from ${where.file}.${also}${left}`, where.file, code, after);
|
|
48900
|
+
}
|
|
48901
|
+
case "move": {
|
|
48902
|
+
const b = refs[1];
|
|
48903
|
+
const bWhere = locOf(b);
|
|
48904
|
+
if (!b || !bWhere) return {
|
|
48905
|
+
kind: "message",
|
|
48906
|
+
tone: "info",
|
|
48907
|
+
text: "Say where: point at the other element while you say “that”."
|
|
48908
|
+
};
|
|
48909
|
+
if (bWhere.file !== where.file) return null;
|
|
48910
|
+
const after = moveElement(code, where.loc, bWhere.loc, intent.where, ast);
|
|
48911
|
+
if (after === code) return {
|
|
48912
|
+
kind: "message",
|
|
48913
|
+
tone: "info",
|
|
48914
|
+
text: `${what} is already ${intent.where} ${describeRef(b)}.`
|
|
48915
|
+
};
|
|
48916
|
+
return edit(`Move ${what} ${intent.where} ${describeRef(b)}`, `Reordered elements in ${where.file}.`, where.file, code, after);
|
|
48009
48917
|
}
|
|
48010
|
-
return edit(`Rename ${what} to “${intent.value}”`, `Changed the text of ${name} in ${where.file}.`, where.file, code, after);
|
|
48011
|
-
}
|
|
48012
|
-
case "remove": {
|
|
48013
|
-
const after = removeElement(code, where.loc, ast);
|
|
48014
|
-
return edit(`Remove ${what}`, `Removed the ${name} element from ${where.file}.`, where.file, code, after);
|
|
48015
|
-
}
|
|
48016
|
-
case "move": {
|
|
48017
|
-
const b = refs[1];
|
|
48018
|
-
const bWhere = locOf(b);
|
|
48019
|
-
if (!b || !bWhere) return {
|
|
48020
|
-
kind: "message",
|
|
48021
|
-
tone: "info",
|
|
48022
|
-
text: "Say where: point at the other element while you say “that”."
|
|
48023
|
-
};
|
|
48024
|
-
if (bWhere.file !== where.file) return null;
|
|
48025
|
-
const after = moveElement(code, where.loc, bWhere.loc, intent.where, ast);
|
|
48026
|
-
if (after === code) return {
|
|
48027
|
-
kind: "message",
|
|
48028
|
-
tone: "info",
|
|
48029
|
-
text: `${what} is already ${intent.where} ${describeRef(b)}.`
|
|
48030
|
-
};
|
|
48031
|
-
return edit(`Move ${what} ${intent.where} ${describeRef(b)}`, `Reordered elements in ${where.file}.`, where.file, code, after);
|
|
48032
48918
|
}
|
|
48919
|
+
} catch (err) {
|
|
48920
|
+
if (err instanceof CodemodError) return null;
|
|
48921
|
+
throw err;
|
|
48033
48922
|
}
|
|
48034
|
-
|
|
48035
|
-
if (err instanceof CodemodError) return null;
|
|
48036
|
-
throw err;
|
|
48923
|
+
return null;
|
|
48037
48924
|
}
|
|
48038
|
-
|
|
48925
|
+
}
|
|
48926
|
+
/** “a, b and c”. */
|
|
48927
|
+
function andList(items) {
|
|
48928
|
+
return items.length <= 1 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
48039
48929
|
}
|
|
48040
48930
|
/** Set one design-system prop on the element at `src`, from the Inspector. */
|
|
48041
48931
|
function propEdit(project, src, prop, value) {
|
|
@@ -48242,6 +49132,18 @@ var REPLAY_REASONS = {
|
|
|
48242
49132
|
en: "something went wrong",
|
|
48243
49133
|
ar: "حدث خطأ"
|
|
48244
49134
|
},
|
|
49135
|
+
disabled: {
|
|
49136
|
+
en: "it was disabled, so a person couldn’t use it either",
|
|
49137
|
+
ar: "كان معطّلًا، فلا يستطيع أحد استخدامه"
|
|
49138
|
+
},
|
|
49139
|
+
secret: {
|
|
49140
|
+
en: "it needs a secret that isn’t saved (the Test tab keeps them)",
|
|
49141
|
+
ar: "يحتاج قيمة سرية غير محفوظة (تحفظها لسان الاختبار)"
|
|
49142
|
+
},
|
|
49143
|
+
"no-effect": {
|
|
49144
|
+
en: "it did nothing this time",
|
|
49145
|
+
ar: "لم يفعل شيئًا هذه المرة"
|
|
49146
|
+
},
|
|
48245
49147
|
stopped: {
|
|
48246
49148
|
en: "you stopped it",
|
|
48247
49149
|
ar: "أوقفتَها أنت"
|
|
@@ -48700,6 +49602,17 @@ function changedLines(screens) {
|
|
|
48700
49602
|
}
|
|
48701
49603
|
//#endregion
|
|
48702
49604
|
//#region server/sessions/analyze.ts
|
|
49605
|
+
/**
|
|
49606
|
+
* Turns a recorded session into findings without AI: a merged timeline,
|
|
49607
|
+
* grouped issues with evidence, and scores. Deterministic, so the same session
|
|
49608
|
+
* always scores the same.
|
|
49609
|
+
*
|
|
49610
|
+
* Long, noisy sessions stay readable: messages and addresses that differ only in
|
|
49611
|
+
* ids, numbers or timestamps are one problem with a count ("GET /api/orders/{id}"),
|
|
49612
|
+
* a problem's repeats past its evidence aren't listed one by one, and each kind of
|
|
49613
|
+
* problem lists its first 200; the rest is counted in `more`. The work is done in
|
|
49614
|
+
* slices (analyzeAsync), so the studio keeps answering while a long one runs.
|
|
49615
|
+
*/
|
|
48703
49616
|
var SLOW_REQUEST_MS = 1500;
|
|
48704
49617
|
/** Occurrences of one problem kept as its evidence (and listed in the timeline); later ones are counted. */
|
|
48705
49618
|
var EVIDENCE = 12;
|
|
@@ -48773,7 +49686,7 @@ function fmtMsAr(ms) {
|
|
|
48773
49686
|
/** An address's parts, without building a URL for each of thousands of requests. */
|
|
48774
49687
|
var URL_PARTS = /^(?:[a-z][\w+.-]*:\/\/[^/?#]*)?([^?#]*)(\?[^#]*)?(#.*)?$/i;
|
|
48775
49688
|
/** The page part of a URL: path, a short query, and the hash when the app routes with it (#/checkout). */
|
|
48776
|
-
function pathOf(url) {
|
|
49689
|
+
function pathOf$1(url) {
|
|
48777
49690
|
const m = /^(?:https?:\/\/[^/?#\s]+)?\/[^\s\\]*$/i.test(url) ? URL_PARTS.exec(url) : null;
|
|
48778
49691
|
if (!m || /(^|\/)\.\.?(\/|$)|%/.test(m[1])) try {
|
|
48779
49692
|
const u = new URL(url, "http://x");
|
|
@@ -49061,15 +49974,19 @@ function* analysis(meta, events, transcript, shots) {
|
|
|
49061
49974
|
});
|
|
49062
49975
|
lastAction = it;
|
|
49063
49976
|
refFile(e.el.src);
|
|
49064
|
-
if (e.el.locator)
|
|
49065
|
-
|
|
49066
|
-
|
|
49067
|
-
|
|
49068
|
-
|
|
49069
|
-
|
|
49070
|
-
|
|
49071
|
-
|
|
49072
|
-
|
|
49977
|
+
if (e.el.locator) {
|
|
49978
|
+
const secret = e.masked ? { secret: e.secret ?? secretName(e.el.label || e.el.name) } : {};
|
|
49979
|
+
steps.push({
|
|
49980
|
+
t: at,
|
|
49981
|
+
kind: "fill",
|
|
49982
|
+
locator: e.el.locator,
|
|
49983
|
+
value: e.masked ? "" : e.value,
|
|
49984
|
+
...secret,
|
|
49985
|
+
description: `Fill ${nameOf(e.el)}`,
|
|
49986
|
+
descriptionAr: `املأ ${nameOfAr(e.el)}`,
|
|
49987
|
+
...likeOf(e.el)
|
|
49988
|
+
});
|
|
49989
|
+
}
|
|
49073
49990
|
if (!e.el.label && e.el.role === "textbox") issue(`nolabel:${e.el.src ?? e.el.locator}`, () => ({
|
|
49074
49991
|
kind: "accessibility",
|
|
49075
49992
|
severity: "medium",
|
|
@@ -49083,7 +50000,7 @@ function* analysis(meta, events, transcript, shots) {
|
|
|
49083
50000
|
}), it);
|
|
49084
50001
|
break;
|
|
49085
50002
|
}
|
|
49086
|
-
case "change":
|
|
50003
|
+
case "change": {
|
|
49087
50004
|
lastAction = add({
|
|
49088
50005
|
t: at,
|
|
49089
50006
|
kind: "action",
|
|
@@ -49092,17 +50009,20 @@ function* analysis(meta, events, transcript, shots) {
|
|
|
49092
50009
|
src: e.el.src
|
|
49093
50010
|
});
|
|
49094
50011
|
refFile(e.el.src);
|
|
50012
|
+
const list = e.checked === void 0 && (e.el.tag === "select" || e.el.role === "combobox" || e.el.role === "listbox");
|
|
49095
50013
|
if (e.el.locator) steps.push({
|
|
49096
50014
|
t: at,
|
|
49097
50015
|
kind: "fill",
|
|
49098
50016
|
locator: e.el.locator,
|
|
49099
50017
|
value: e.value,
|
|
49100
50018
|
...e.checked !== void 0 ? { checked: e.checked } : {},
|
|
50019
|
+
...list ? { select: e.options?.length ? e.options : [e.value] } : {},
|
|
49101
50020
|
description: `Set ${nameOf(e.el)}`,
|
|
49102
50021
|
descriptionAr: `اضبط ${nameOfAr(e.el)}`,
|
|
49103
50022
|
...likeOf(e.el)
|
|
49104
50023
|
});
|
|
49105
50024
|
break;
|
|
50025
|
+
}
|
|
49106
50026
|
case "submit":
|
|
49107
50027
|
lastAction = add({
|
|
49108
50028
|
t: at,
|
|
@@ -49139,15 +50059,15 @@ function* analysis(meta, events, transcript, shots) {
|
|
|
49139
50059
|
add({
|
|
49140
50060
|
t: at,
|
|
49141
50061
|
kind: "navigation",
|
|
49142
|
-
text: `${how} ${pathOf(e.url)}${e.title ? ` (“${e.title}”)` : ""}`,
|
|
49143
|
-
textAr: `${howAr} ${iso(pathOf(e.url))}${e.title ? ` («${iso(e.title)}»)` : ""}`
|
|
50062
|
+
text: `${how} ${pathOf$1(e.url)}${e.title ? ` (“${e.title}”)` : ""}`,
|
|
50063
|
+
textAr: `${howAr} ${iso(pathOf$1(e.url))}${e.title ? ` («${iso(e.title)}»)` : ""}`
|
|
49144
50064
|
});
|
|
49145
50065
|
if (e.type === "page" && !steps.length) steps.push({
|
|
49146
50066
|
t: at,
|
|
49147
50067
|
kind: "navigate",
|
|
49148
50068
|
url: e.url,
|
|
49149
|
-
description: `Open ${pathOf(e.url)}`,
|
|
49150
|
-
descriptionAr: `افتح ${iso(pathOf(e.url))}`
|
|
50069
|
+
description: `Open ${pathOf$1(e.url)}`,
|
|
50070
|
+
descriptionAr: `افتح ${iso(pathOf$1(e.url))}`
|
|
49151
50071
|
});
|
|
49152
50072
|
break;
|
|
49153
50073
|
}
|
|
@@ -49239,7 +50159,7 @@ function* analysis(meta, events, transcript, shots) {
|
|
|
49239
50159
|
case "net": {
|
|
49240
50160
|
const n = 1 + (e.repeat ?? 0);
|
|
49241
50161
|
netTotal += n;
|
|
49242
|
-
const page = pathOf(e.url);
|
|
50162
|
+
const page = pathOf$1(e.url);
|
|
49243
50163
|
const route = normalizePath(page.split("?")[0]);
|
|
49244
50164
|
const key = `${e.method} ${route}`;
|
|
49245
50165
|
const ep = endpoints.get(key) ?? {
|
|
@@ -49798,6 +50718,16 @@ function stackSource(stack) {
|
|
|
49798
50718
|
* from the analysis alone.
|
|
49799
50719
|
*/
|
|
49800
50720
|
/** mm:ss from the start of the session. */
|
|
50721
|
+
/** What a restart of the studio during a recording did to it. */
|
|
50722
|
+
function restartWords(r) {
|
|
50723
|
+
return r.resumed ? {
|
|
50724
|
+
en: "the page recording it went on, and sent what it kept meanwhile, so nothing is missing.",
|
|
50725
|
+
ar: "واصلت الصفحة التسجيل وأرسلت ما احتفظت به في أثناء ذلك، فلم يضع شيء."
|
|
50726
|
+
} : {
|
|
50727
|
+
en: "the recording ended there; nothing after it was recorded.",
|
|
50728
|
+
ar: "انتهى التسجيل عندها، ولم يُسجَّل شيء بعدها."
|
|
50729
|
+
};
|
|
50730
|
+
}
|
|
49801
50731
|
function clock(t, start) {
|
|
49802
50732
|
const s = Math.max(0, Math.round((t - start) / 1e3));
|
|
49803
50733
|
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
|
@@ -50009,6 +50939,7 @@ function evidencePack(input) {
|
|
|
50009
50939
|
if (meta.git) push(`Git: ${meta.git.branch} @ ${meta.git.commit}${meta.git.dirty ? " (uncommitted changes)" : ""}`);
|
|
50010
50940
|
push(`Pages: ${analysis.pages.map((p) => `${p.url} (${Math.round(p.ms / 1e3)} s)`).join(" → ") || "unknown"}`);
|
|
50011
50941
|
push(`Narration: ${analysis.transcript.length} sentences, language setting ${meta.lang}`);
|
|
50942
|
+
for (const r of meta.restarts ?? []) push(`Studio restart at ${clock(r.at, t0)}: ${restartWords(r).en}`);
|
|
50012
50943
|
push("</session>");
|
|
50013
50944
|
const s = analysis.scores;
|
|
50014
50945
|
push("<scores computed=\"deterministically\">");
|
|
@@ -50183,7 +51114,7 @@ var MOMENTS = {
|
|
|
50183
51114
|
/** When the screen was read: after which action, or at which moment; a page shown in full says which page. */
|
|
50184
51115
|
function screenMoment(s) {
|
|
50185
51116
|
const when = s.after ? `after ${s.after.id ? `${s.after.id} ` : ""}${s.after.text}` : MOMENTS[s.moment] ?? "";
|
|
50186
|
-
return s.diff ? when : `${pathOf(s.url)} ${when} (in full)`.replace(/\s+/g, " ");
|
|
51117
|
+
return s.diff ? when : `${pathOf$1(s.url)} ${when} (in full)`.replace(/\s+/g, " ");
|
|
50187
51118
|
}
|
|
50188
51119
|
var SYSTEM = `You are a senior QA engineer and UX researcher reviewing a recorded test session of a web app that is under development. The tester used the app while talking out loud; the studio recorded every action, error, request and performance signal, transcribed the narration on-device, linked each sentence to what was being pointed at, and grouped issues automatically.
|
|
50189
51120
|
|
|
@@ -50547,6 +51478,14 @@ function localReport(meta, analysis, note, lang = "en") {
|
|
|
50547
51478
|
effort: "small"
|
|
50548
51479
|
});
|
|
50549
51480
|
const pages = analysis.pages.length;
|
|
51481
|
+
const restarted = (meta.restarts ?? []).map((r) => ar ? `أُعيد تشغيل الاستوديو عند ${clock(r.at, meta.startedAt)}: ${restartWords(r).ar}` : `The studio restarted at ${clock(r.at, meta.startedAt)}: ${restartWords(r).en}`).join(" ");
|
|
51482
|
+
if (restarted) note = note ? {
|
|
51483
|
+
en: `${note.en} ${restarted}`,
|
|
51484
|
+
ar: `${note.ar} ${restarted}`
|
|
51485
|
+
} : {
|
|
51486
|
+
en: restarted,
|
|
51487
|
+
ar: restarted
|
|
51488
|
+
};
|
|
50550
51489
|
const summary = ar ? `اختبار لمدة ${durationWordsAr(analysis.durationMs)} على ${arCount(pages, AR_UNITS.page)}. النقرات: ${counts.clicks}، الإدخالات: ${counts.inputs}، الجمل المنطوقة: ${analysis.transcript.length}. ${top.length ? `أهم مشكلة: ${title(top[0])}.` : "لم تُسجَّل أخطاء ولا طلبات فاشلة ولا علامات تعثّر."}${note ? ` ${note.ar}` : ""}` : `${durationWords(analysis.durationMs)} of testing across ${pages} page${pages === 1 ? "" : "s"}: ${counts.clicks} click${counts.clicks === 1 ? "" : "s"}, ${counts.inputs} input${counts.inputs === 1 ? "" : "s"} and ${analysis.transcript.length} spoken comment${analysis.transcript.length === 1 ? "" : "s"}. ${top.length ? `The most important problem: ${top[0].title}.` : "No errors, failed requests or signs of frustration were recorded."}${note ? ` ${note.en}` : ""}`;
|
|
50551
51490
|
const notes = (k) => (ar ? s[k].notesAr?.[0] ?? s[k].notes[0] : s[k].notes[0]) ?? "";
|
|
50552
51491
|
return {
|
|
@@ -50853,6 +51792,19 @@ function sendFile(res, file, type, range) {
|
|
|
50853
51792
|
stream.pipe(res);
|
|
50854
51793
|
});
|
|
50855
51794
|
}
|
|
51795
|
+
/** This computer's own names: a page at 127.0.0.1:5173 is the app at localhost:5173. */
|
|
51796
|
+
var LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$/;
|
|
51797
|
+
/** Whether a page's origin is the address `url` is at (its scheme, port and name, any of this computer's names alike). */
|
|
51798
|
+
function sameAddress(url, from) {
|
|
51799
|
+
try {
|
|
51800
|
+
const a = new URL(url);
|
|
51801
|
+
const b = new URL(from);
|
|
51802
|
+
const port = (u) => u.port || (u.protocol === "https:" ? "443" : "80");
|
|
51803
|
+
return a.protocol === b.protocol && port(a) === port(b) && (a.hostname === b.hostname || LOOPBACK.test(a.hostname) && LOOPBACK.test(b.hostname));
|
|
51804
|
+
} catch {
|
|
51805
|
+
return false;
|
|
51806
|
+
}
|
|
51807
|
+
}
|
|
50856
51808
|
function sessionRoutes(router, deps) {
|
|
50857
51809
|
const { speech } = deps;
|
|
50858
51810
|
const of = (id) => deps.forSession(id).sessions;
|
|
@@ -50863,14 +51815,28 @@ function sessionRoutes(router, deps) {
|
|
|
50863
51815
|
};
|
|
50864
51816
|
const origin = (raw) => String(raw.headers.host ? `http://${raw.headers.host}` : "");
|
|
50865
51817
|
/**
|
|
51818
|
+
* Whether a page may start recording into the open app's sessions, or make the studio load its speech models: the
|
|
51819
|
+
* studio's own page, or the open app's (at its own address, or the proxy's in front of it). Any other page on this
|
|
51820
|
+
* computer could otherwise fill the app's history with what it likes, and have it reported on.
|
|
51821
|
+
*/
|
|
51822
|
+
const fromOpenApp = (raw) => {
|
|
51823
|
+
if (sameOrigin(raw)) return true;
|
|
51824
|
+
const from = typeof raw.headers.origin === "string" ? raw.headers.origin : "";
|
|
51825
|
+
if (!from) return true;
|
|
51826
|
+
const ws = deps.active();
|
|
51827
|
+
return !!ws && [ws.env?.url, ws.frameUrl].some((url) => !!url && sameAddress(url, from));
|
|
51828
|
+
};
|
|
51829
|
+
/**
|
|
50866
51830
|
* Writing to a session (or following it) from an app's page takes the session's key, given to whoever started it:
|
|
50867
51831
|
* another page on this computer can't add to or read someone else's recording. The studio's own page needs none.
|
|
50868
51832
|
*/
|
|
50869
51833
|
const writer = (req) => {
|
|
50870
|
-
|
|
50871
|
-
if (!
|
|
51834
|
+
const sessions = of(req.params.id);
|
|
51835
|
+
if (!sameOrigin(req.raw) && !holdsKey(req, sessions, req.params.id)) throw new HttpError(403, "This page can’t write to that session.");
|
|
51836
|
+
sessions.resumed(req.params.id);
|
|
50872
51837
|
};
|
|
50873
51838
|
router.post("/sessions", (req) => {
|
|
51839
|
+
if (!fromOpenApp(req.raw)) throw new HttpError(403, "Only the app open in the studio can record a session. Open this app in the studio first.");
|
|
50874
51840
|
const body = req.body ?? {};
|
|
50875
51841
|
const lang = LANGS.includes(body.lang) ? body.lang : "auto";
|
|
50876
51842
|
const replayOf = body.source === "replay" && typeof body.replayOf === "string" ? body.replayOf : void 0;
|
|
@@ -50908,7 +51874,8 @@ function sessionRoutes(router, deps) {
|
|
|
50908
51874
|
};
|
|
50909
51875
|
});
|
|
50910
51876
|
router.get("/sessions/active", (req) => {
|
|
50911
|
-
const
|
|
51877
|
+
const open = deps.active();
|
|
51878
|
+
const ws = open?.sessions.active() ? open : deps.recording?.() ?? open;
|
|
50912
51879
|
const m = ws?.sessions.active() ?? null;
|
|
50913
51880
|
if (!sameOrigin(req.raw)) return {
|
|
50914
51881
|
session: m && {
|
|
@@ -51003,7 +51970,10 @@ function sessionRoutes(router, deps) {
|
|
|
51003
51970
|
router.put("/sessions/:id", (req) => {
|
|
51004
51971
|
const name = asString(req.body?.name, "name", 120).trim();
|
|
51005
51972
|
if (!name) throw new HttpError(400, "Name it something");
|
|
51006
|
-
return { session: of(req.params.id).store.update(req.params.id, {
|
|
51973
|
+
return { session: of(req.params.id).store.update(req.params.id, {
|
|
51974
|
+
name,
|
|
51975
|
+
renamed: true
|
|
51976
|
+
}) };
|
|
51007
51977
|
});
|
|
51008
51978
|
router.delete("/sessions/:id", (req) => {
|
|
51009
51979
|
of(req.params.id).remove(req.params.id);
|
|
@@ -51114,6 +52084,7 @@ function sessionRoutes(router, deps) {
|
|
|
51114
52084
|
return speech.status();
|
|
51115
52085
|
});
|
|
51116
52086
|
router.post("/speech/warm", async (req) => {
|
|
52087
|
+
if (!fromOpenApp(req.raw)) throw new HttpError(403, "Only the app open in the studio can do that.");
|
|
51117
52088
|
const mode = req.query.get("mode");
|
|
51118
52089
|
if (mode) {
|
|
51119
52090
|
const m = mode === "en" ? "en" : mode === "ar" ? "ar" : "auto";
|
|
@@ -51495,7 +52466,7 @@ var SessionStore = class {
|
|
|
51495
52466
|
hour: "2-digit",
|
|
51496
52467
|
minute: "2-digit"
|
|
51497
52468
|
})}`,
|
|
51498
|
-
appUrl: input.appUrl,
|
|
52469
|
+
appUrl: redactUrl(input.appUrl),
|
|
51499
52470
|
source: input.source,
|
|
51500
52471
|
...input.replayOf ? { replayOf: input.replayOf } : {},
|
|
51501
52472
|
lang: input.lang,
|
|
@@ -51602,6 +52573,27 @@ var SessionStore = class {
|
|
|
51602
52573
|
const st = this.open.get(meta.id);
|
|
51603
52574
|
if (st) st.dirty = false;
|
|
51604
52575
|
}
|
|
52576
|
+
/**
|
|
52577
|
+
* A recording's write key, kept beside it (only its owner can read it) so a studio that restarts still takes what
|
|
52578
|
+
* the page recording it sends: that page alone was given it. Gone once the recording ends.
|
|
52579
|
+
*/
|
|
52580
|
+
saveKey(id, key) {
|
|
52581
|
+
writeFileAtomic(path.join(this.dir(id), ".key"), key, {
|
|
52582
|
+
mode: 384,
|
|
52583
|
+
sync: false
|
|
52584
|
+
});
|
|
52585
|
+
}
|
|
52586
|
+
savedKey(id) {
|
|
52587
|
+
try {
|
|
52588
|
+
const key = fs.readFileSync(path.join(this.dir(id), ".key"), "utf8").trim();
|
|
52589
|
+
return /^[0-9a-f]{32}$/.test(key) ? key : null;
|
|
52590
|
+
} catch {
|
|
52591
|
+
return null;
|
|
52592
|
+
}
|
|
52593
|
+
}
|
|
52594
|
+
dropKey(id) {
|
|
52595
|
+
if (ID.test(id)) fs.rmSync(path.join(this.root, id, ".key"), { force: true });
|
|
52596
|
+
}
|
|
51605
52597
|
/** Stop the timer and write what's pending (the studio is shutting down or restarting). */
|
|
51606
52598
|
close() {
|
|
51607
52599
|
clearInterval(this.saveTimer);
|
|
@@ -51653,10 +52645,10 @@ var SessionStore = class {
|
|
|
51653
52645
|
if (!e || typeof e !== "object" || typeof e.seq !== "number" || typeof e.t !== "number" || typeof e.type !== "string") continue;
|
|
51654
52646
|
if (seen.has(e.seq)) continue;
|
|
51655
52647
|
seen.add(e.seq);
|
|
51656
|
-
fresh.push({
|
|
52648
|
+
fresh.push(scrubEvent({
|
|
51657
52649
|
...e,
|
|
51658
52650
|
page
|
|
51659
|
-
});
|
|
52651
|
+
}));
|
|
51660
52652
|
}
|
|
51661
52653
|
st.lastActivity = Date.now();
|
|
51662
52654
|
if (!fresh.length) return fresh;
|
|
@@ -51912,7 +52904,7 @@ var SessionStore = class {
|
|
|
51912
52904
|
/** The report in the language spoken; a new one replaces its translation too. */
|
|
51913
52905
|
saveReport(id, r) {
|
|
51914
52906
|
const dir = this.dir(id);
|
|
51915
|
-
writeFileAtomic(path.join(dir, "report.json"),
|
|
52907
|
+
writeFileAtomic(path.join(dir, "report.json"), masked(r));
|
|
51916
52908
|
for (const lang of ["en", "ar"]) fs.rmSync(path.join(dir, `report-${lang}.json`), { force: true });
|
|
51917
52909
|
}
|
|
51918
52910
|
report(id) {
|
|
@@ -51923,7 +52915,7 @@ var SessionStore = class {
|
|
|
51923
52915
|
}
|
|
51924
52916
|
}
|
|
51925
52917
|
saveTranslation(id, r) {
|
|
51926
|
-
writeFileAtomic(path.join(this.dir(id), `report-${r.lang}.json`),
|
|
52918
|
+
writeFileAtomic(path.join(this.dir(id), `report-${r.lang}.json`), masked(r));
|
|
51927
52919
|
}
|
|
51928
52920
|
/** The report in the other language, once written. */
|
|
51929
52921
|
translation(id) {
|
|
@@ -51977,6 +52969,13 @@ var SessionStore = class {
|
|
|
51977
52969
|
}
|
|
51978
52970
|
}
|
|
51979
52971
|
};
|
|
52972
|
+
/**
|
|
52973
|
+
* A report as it is kept: what the AI wrote may repeat a key or a card number it saw (an older plugin's screenshot,
|
|
52974
|
+
* a response body), and every text in it is masked before it is saved, shown, exported or sent anywhere.
|
|
52975
|
+
*/
|
|
52976
|
+
function masked(r) {
|
|
52977
|
+
return JSON.stringify(r, (_key, value) => typeof value === "string" ? maskSecrets(value) : value, 1);
|
|
52978
|
+
}
|
|
51980
52979
|
//#endregion
|
|
51981
52980
|
//#region server/sessions/verify.ts
|
|
51982
52981
|
/**
|
|
@@ -52027,6 +53026,30 @@ function requested(replay, endpoint) {
|
|
|
52027
53026
|
const want = normKey(endpoint);
|
|
52028
53027
|
return replay.network.endpoints.some((e) => normKey(`${e.method} ${e.url}`) === want);
|
|
52029
53028
|
}
|
|
53029
|
+
/** The steps a replay plays, in order (as the studio's replayer picks them). */
|
|
53030
|
+
var playable = (a) => a.steps.filter((s) => s.kind !== "navigate" && !!s.locator);
|
|
53031
|
+
/** A session's reads of the screen were thinned to stay within a size: a read missing from them says nothing. */
|
|
53032
|
+
var thinned = (screens) => screens.length >= 140 || screens.reduce((n, s) => n + s.lines.reduce((m, l) => m + l.length + 1, 0), 0) > 5e4;
|
|
53033
|
+
/**
|
|
53034
|
+
* The step a problem came after, when it changed the screen the first time and changed nothing after the same step
|
|
53035
|
+
* in the replay: it was done, but it didn't do what it did. Whatever came of it then can't be judged from the replay.
|
|
53036
|
+
*/
|
|
53037
|
+
function noEffect(original, replay, issue) {
|
|
53038
|
+
const steps = playable(original);
|
|
53039
|
+
let i = -1;
|
|
53040
|
+
for (let k = 0; k < steps.length && steps[k].t <= issue.t; k++) i = k;
|
|
53041
|
+
if (i < 0) return null;
|
|
53042
|
+
const step = steps[i];
|
|
53043
|
+
const end = steps[i + 1]?.t ?? Infinity;
|
|
53044
|
+
const screens = original.screens ?? [];
|
|
53045
|
+
const at = screens.findIndex((s) => !!s.after && s.t > step.t && s.t < end);
|
|
53046
|
+
if (at < 0) return null;
|
|
53047
|
+
const again = replay.screens ?? [];
|
|
53048
|
+
if (thinned(screens) || thinned(again)) return null;
|
|
53049
|
+
const text = screens[at].after.text;
|
|
53050
|
+
const nth = screens.slice(0, at).filter((s) => s.after?.text === text).length;
|
|
53051
|
+
return again.filter((s) => s.after?.text === text)[nth] ? null : step;
|
|
53052
|
+
}
|
|
52030
53053
|
function verifyIssues(original, replay, outcome) {
|
|
52031
53054
|
const failed = outcome.steps.failed;
|
|
52032
53055
|
const recorded = replay.pages.length > 0 || replay.timeline.length > 0;
|
|
@@ -52058,7 +53081,8 @@ function verifyIssues(original, replay, outcome) {
|
|
|
52058
53081
|
continue;
|
|
52059
53082
|
}
|
|
52060
53083
|
if (o.t >= reachedUntil && failed) {
|
|
52061
|
-
|
|
53084
|
+
const stuck = failed.reason === "disabled" && /^(?:dead|disabled|rage):/.test(key) && o.t - failed.t < 3e3;
|
|
53085
|
+
out[o.id] = stuck ? still(`It is still disabled: ${failed.description}.`, `ما زال معطّلًا: ${failed.descriptionAr ?? failed.description}.`) : unknown(`The replay stopped before this: ${failed.description} (${replayReason(failed.reason, "en")}).`, `توقّفت الإعادة قبل هذا: ${failed.descriptionAr ?? failed.description} (${replayReason(failed.reason, "ar")}).`);
|
|
52062
53086
|
continue;
|
|
52063
53087
|
}
|
|
52064
53088
|
if (key.startsWith("inp:") || key.startsWith("disabled:")) {
|
|
@@ -52075,6 +53099,11 @@ function verifyIssues(original, replay, outcome) {
|
|
|
52075
53099
|
out[o.id] = unknown("The replay didn’t make this request, so it couldn’t tell.", "لم ترسل الإعادة هذا الطلب، فلم تستطع الحكم.");
|
|
52076
53100
|
continue;
|
|
52077
53101
|
}
|
|
53102
|
+
const idle = noEffect(original, replay, o);
|
|
53103
|
+
if (idle) {
|
|
53104
|
+
out[o.id] = unknown(`The step before it, ${idle.description}, changed nothing on screen in the replay (it did the first time), so it can’t tell.`, `الخطوة التي سبقته، ${idle.descriptionAr ?? idle.description}، لم تغيّر شيئًا على الشاشة في الإعادة (وغيّرت في المرة الأولى)، فتعذّر الحكم.`);
|
|
53105
|
+
continue;
|
|
53106
|
+
}
|
|
52078
53107
|
out[o.id] = fixed("It didn’t happen in the replay.", "لم يحدث في الإعادة.");
|
|
52079
53108
|
}
|
|
52080
53109
|
return out;
|
|
@@ -52416,6 +53445,8 @@ var scoresFrom = (s) => ({
|
|
|
52416
53445
|
notes: []
|
|
52417
53446
|
}
|
|
52418
53447
|
});
|
|
53448
|
+
/** How long a recording found unfinished at startup waits for its page to come back (it retries every few seconds). */
|
|
53449
|
+
var RESUME_MS = 6e4;
|
|
52419
53450
|
var Sessions = class {
|
|
52420
53451
|
deps;
|
|
52421
53452
|
store;
|
|
@@ -52436,16 +53467,56 @@ var Sessions = class {
|
|
|
52436
53467
|
notable = /* @__PURE__ */ new Map();
|
|
52437
53468
|
/** Each recording's key: an app's page writes to a session, and follows it, only with the key it was given. */
|
|
52438
53469
|
keys = /* @__PURE__ */ new Map();
|
|
53470
|
+
/** Recordings found unfinished when the studio started: each finishes unless its page comes back in time. */
|
|
53471
|
+
recovering = /* @__PURE__ */ new Map();
|
|
52439
53472
|
/** Per recording: when its last problem went out, and the newest one waiting to (see ISSUE_EVERY_MS). */
|
|
52440
53473
|
issueFrames = /* @__PURE__ */ new Map();
|
|
52441
53474
|
constructor(deps) {
|
|
52442
53475
|
this.deps = deps;
|
|
52443
53476
|
this.store = new SessionStore(deps.project);
|
|
52444
|
-
for (const m of this.store.list()) if (m.status === "recording"
|
|
53477
|
+
for (const m of this.store.list()) if (m.status === "recording") this.recover(m);
|
|
53478
|
+
else if (m.status === "processing") this.finish(m.id, "Recovered after a restart");
|
|
52445
53479
|
}
|
|
52446
53480
|
emit(live) {
|
|
52447
53481
|
this.deps.emit("session", live);
|
|
52448
53482
|
}
|
|
53483
|
+
/**
|
|
53484
|
+
* A session the studio was recording when it stopped. The page recording it keeps what it records while the
|
|
53485
|
+
* studio is away, and sends it once the studio is back: for a while its key is taken again and the recording
|
|
53486
|
+
* goes on. If nothing comes, it is finished with what it has, and its report says where it ended.
|
|
53487
|
+
*/
|
|
53488
|
+
recover(m) {
|
|
53489
|
+
const key = this.store.savedKey(m.id);
|
|
53490
|
+
if (!key) return void this.finish(m.id, "Recovered after a restart");
|
|
53491
|
+
this.keys.set(m.id, key);
|
|
53492
|
+
this.store.update(m.id, { restarts: [...m.restarts ?? [], {
|
|
53493
|
+
at: Date.now(),
|
|
53494
|
+
resumed: false
|
|
53495
|
+
}] });
|
|
53496
|
+
const timer = setTimeout(() => {
|
|
53497
|
+
this.recovering.delete(m.id);
|
|
53498
|
+
if (this.store.meta(m.id).status === "recording") this.finish(m.id, "Recovered after a restart");
|
|
53499
|
+
}, RESUME_MS);
|
|
53500
|
+
timer.unref?.();
|
|
53501
|
+
this.recovering.set(m.id, timer);
|
|
53502
|
+
}
|
|
53503
|
+
/** The page recording a session sent something: after a restart, that recording goes on. */
|
|
53504
|
+
resumed(id) {
|
|
53505
|
+
const timer = this.recovering.get(id);
|
|
53506
|
+
if (!timer) return;
|
|
53507
|
+
clearTimeout(timer);
|
|
53508
|
+
this.recovering.delete(id);
|
|
53509
|
+
const restarts = this.store.meta(id).restarts ?? [];
|
|
53510
|
+
this.store.update(id, { restarts: restarts.map((r, i) => i === restarts.length - 1 ? {
|
|
53511
|
+
...r,
|
|
53512
|
+
resumed: true
|
|
53513
|
+
} : r) });
|
|
53514
|
+
this.emit({
|
|
53515
|
+
kind: "status",
|
|
53516
|
+
id,
|
|
53517
|
+
status: "recording"
|
|
53518
|
+
});
|
|
53519
|
+
}
|
|
52449
53520
|
active() {
|
|
52450
53521
|
return this.store.active();
|
|
52451
53522
|
}
|
|
@@ -52458,7 +53529,9 @@ var Sessions = class {
|
|
|
52458
53529
|
}
|
|
52459
53530
|
const meta = this.store.create(input);
|
|
52460
53531
|
this.segmentNo.set(meta.id, 0);
|
|
52461
|
-
|
|
53532
|
+
const key = randomBytes(16).toString("hex");
|
|
53533
|
+
this.keys.set(meta.id, key);
|
|
53534
|
+
this.store.saveKey(meta.id, key);
|
|
52462
53535
|
this.emit({
|
|
52463
53536
|
kind: "status",
|
|
52464
53537
|
id: meta.id,
|
|
@@ -52617,6 +53690,9 @@ var Sessions = class {
|
|
|
52617
53690
|
stop(id, endedAt) {
|
|
52618
53691
|
const meta = this.store.meta(id);
|
|
52619
53692
|
if (meta.status !== "recording") return meta;
|
|
53693
|
+
const recovering = this.recovering.get(id);
|
|
53694
|
+
if (recovering) clearTimeout(recovering);
|
|
53695
|
+
this.recovering.delete(id);
|
|
52620
53696
|
const now = Date.now();
|
|
52621
53697
|
const end = typeof endedAt === "number" && endedAt >= meta.startedAt && endedAt <= now ? endedAt : now;
|
|
52622
53698
|
const updated = this.store.update(id, {
|
|
@@ -52649,6 +53725,7 @@ var Sessions = class {
|
|
|
52649
53725
|
async finish(id, note) {
|
|
52650
53726
|
if (this.processing.has(id)) return;
|
|
52651
53727
|
this.processing.add(id);
|
|
53728
|
+
this.store.dropKey(id);
|
|
52652
53729
|
try {
|
|
52653
53730
|
const meta = this.store.meta(id);
|
|
52654
53731
|
if (meta.status === "recording" || !meta.endedAt) this.store.update(id, {
|
|
@@ -52988,6 +54065,8 @@ var Sessions = class {
|
|
|
52988
54065
|
this.countTimers.clear();
|
|
52989
54066
|
for (const f of this.issueFrames.values()) if (f.timer) clearTimeout(f.timer);
|
|
52990
54067
|
this.issueFrames.clear();
|
|
54068
|
+
for (const t of this.recovering.values()) clearTimeout(t);
|
|
54069
|
+
this.recovering.clear();
|
|
52991
54070
|
this.store.close();
|
|
52992
54071
|
}
|
|
52993
54072
|
remove(id) {
|
|
@@ -53274,11 +54353,13 @@ function mcpTools(deps) {
|
|
|
53274
54353
|
const w = open();
|
|
53275
54354
|
const c = deps.context.app === w.app.id ? deps.context : null;
|
|
53276
54355
|
const lines = [`App: ${w.app.name} (${w.env.label}: ${w.env.url})`, w.project.hasCode ? `Code: ${w.project.root}` : "Code: not on this computer (linked by address only)"];
|
|
53277
|
-
if (c?.pageUrl) lines.push(`Page: ${c.pageUrl}`);
|
|
54356
|
+
if (c?.pageUrl) lines.push(`Page: ${redactUrl(c.pageUrl)}`);
|
|
54357
|
+
const key = (r) => `${r.src ?? ""}|${r.label}|${r.text}`;
|
|
54358
|
+
const selected = new Set(c?.selection.map(key) ?? []);
|
|
53278
54359
|
const refs = c ? [...c.selection.map((r) => ({
|
|
53279
54360
|
...r,
|
|
53280
54361
|
role: "selected"
|
|
53281
|
-
})), ...c.pins.map((r) => ({
|
|
54362
|
+
})), ...c.pins.filter((r) => !selected.has(key(r))).map((r) => ({
|
|
53282
54363
|
...r,
|
|
53283
54364
|
role: "pinned"
|
|
53284
54365
|
}))] : [];
|
|
@@ -53289,7 +54370,7 @@ function mcpTools(deps) {
|
|
|
53289
54370
|
lines.push("", `Pointed at (${ago(c.at)}):`);
|
|
53290
54371
|
(await deps.mapRefs(refs, c?.pageUrl ?? void 0)).forEach((r, i) => {
|
|
53291
54372
|
const where = r.src ? ` at ${r.src}${r.guessed ? " (a guess: found by searching the code for its text, not reported by the app; check it)" : ""}` : " (not traced to a file)";
|
|
53292
|
-
lines.push(`${i + 1}. ${refs[i].role}: ${r.label || r.name}${r.text ? ` showing “${r.text.slice(0, 80)}”` : ""}${where}`);
|
|
54373
|
+
lines.push(`${i + 1}. ${refs[i].role}: ${maskSecrets(r.label || r.name)}${r.text ? ` showing “${maskSecrets(r.text).slice(0, 80)}”` : ""}${where}`);
|
|
53293
54374
|
const at = r.src ? parseSrc(r.src) : null;
|
|
53294
54375
|
if (at && w.project.hasCode && w.project.exists(at.file)) {
|
|
53295
54376
|
const text = w.project.read(at.file).split("\n");
|
|
@@ -53298,7 +54379,8 @@ function mcpTools(deps) {
|
|
|
53298
54379
|
lines.push("```", ...text.slice(from - 1, to).map((l, k) => `${String(from + k).padStart(4)}${from + k === at.line ? ">" : " "} ${l}`), "```");
|
|
53299
54380
|
}
|
|
53300
54381
|
});
|
|
53301
|
-
|
|
54382
|
+
const said = deps.context;
|
|
54383
|
+
if (said.said && said.saidApp === w.app.id) lines.push("", `Last said in the studio (${ago(said.saidAt ?? said.at)}): “${maskSecrets(said.said)}”`);
|
|
53302
54384
|
return lines.join("\n");
|
|
53303
54385
|
}
|
|
53304
54386
|
},
|
|
@@ -54407,10 +55489,80 @@ function installSkills(root, skills) {
|
|
|
54407
55489
|
return written;
|
|
54408
55490
|
}
|
|
54409
55491
|
//#endregion
|
|
55492
|
+
//#region shared/steps.ts
|
|
55493
|
+
/**
|
|
55494
|
+
* Recorded steps as a test may hold them, the same in the studio's page and its server: a secret by its name only,
|
|
55495
|
+
* and a choice in a list as a choice. Steps an older plugin recorded (a password in clear, a list written as a
|
|
55496
|
+
* fill) are put right here, before anyone sees them.
|
|
55497
|
+
*/
|
|
55498
|
+
/** A secret's name as a test reads it: capitals, digits and underscores (PASSWORD, CARD_NUMBER). */
|
|
55499
|
+
var secretKey$2 = (name) => secretEnvName(name).slice(SECRET_ENV_PREFIX.length);
|
|
55500
|
+
/** The field a locator names, in words: `page.getByLabel('Password')` → Password. */
|
|
55501
|
+
function fieldWords(step) {
|
|
55502
|
+
return `${Array.from((step.locator ?? "").matchAll(/'((?:[^'\\]|\\.)*)'/g), (m) => m[1]).join(" ")} ${/“([^”]*)”[^“]*$/.exec(step.description)?.[1] ?? ""}`;
|
|
55503
|
+
}
|
|
55504
|
+
/**
|
|
55505
|
+
* Steps as a test may hold them, whoever recorded them: a value typed into a field called like a secret (an older
|
|
55506
|
+
* plugin's “Type “hunter2” into the “Password” field”), or one that looks like a key or a card number, becomes a
|
|
55507
|
+
* secret by name with no value, and its description loses the value; a choice in a list an older plugin wrote as a
|
|
55508
|
+
* fill (“Choose …”) becomes a choice.
|
|
55509
|
+
*/
|
|
55510
|
+
function sanitizeSteps(steps) {
|
|
55511
|
+
return steps.map((step) => {
|
|
55512
|
+
if (step.kind !== "fill" || step.checked !== void 0) return step;
|
|
55513
|
+
if (step.secret) return {
|
|
55514
|
+
...step,
|
|
55515
|
+
value: "",
|
|
55516
|
+
secret: secretKey$2(step.secret)
|
|
55517
|
+
};
|
|
55518
|
+
if (!step.select && !step.values && /^Choose “/.test(step.description)) return {
|
|
55519
|
+
...step,
|
|
55520
|
+
values: [step.value ?? ""]
|
|
55521
|
+
};
|
|
55522
|
+
if (step.select || step.values) return step;
|
|
55523
|
+
const words = fieldWords(step);
|
|
55524
|
+
if (isSecretHint(words) || looksSecret(step.value ?? "")) {
|
|
55525
|
+
const into = / into (.+)$/.exec(step.description)?.[1];
|
|
55526
|
+
return {
|
|
55527
|
+
...step,
|
|
55528
|
+
value: "",
|
|
55529
|
+
secret: secretKey$2(secretName(/'([^']*)'/.exec(step.locator ?? "")?.[1] ?? words)),
|
|
55530
|
+
description: into ? `Type ${MASK} into ${into}` : `Type ${MASK}`
|
|
55531
|
+
};
|
|
55532
|
+
}
|
|
55533
|
+
return {
|
|
55534
|
+
...step,
|
|
55535
|
+
value: maskSecrets(step.value ?? ""),
|
|
55536
|
+
description: maskSecrets(step.description)
|
|
55537
|
+
};
|
|
55538
|
+
});
|
|
55539
|
+
}
|
|
55540
|
+
//#endregion
|
|
55541
|
+
//#region shared/text.ts
|
|
55542
|
+
/**
|
|
55543
|
+
* A check on a whole row receives "Total$78.40" when "$80.80" was expected:
|
|
55544
|
+
* pick out the part that has the same shape as the expectation.
|
|
55545
|
+
*/
|
|
55546
|
+
function focusOn(received, expected) {
|
|
55547
|
+
if (!received || !expected || received === expected) return received;
|
|
55548
|
+
const shape = expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\d+/g, "\\d+");
|
|
55549
|
+
if (shape === expected || !/\\d\+/.test(shape)) return received;
|
|
55550
|
+
return new RegExp(shape).exec(received)?.[0] ?? received;
|
|
55551
|
+
}
|
|
55552
|
+
/** What a check says should be, after the thing it is about: "should show “$80.80”", "should be checked". */
|
|
55553
|
+
function shouldBe(e, quote = (s) => `“${s}”`) {
|
|
55554
|
+
if (e.field === "checked") return e.expected === "not checked" ? "should not be checked" : "should be checked";
|
|
55555
|
+
if (e.field === "select") return `should have ${quote(e.expected)} chosen`;
|
|
55556
|
+
return `should show ${quote(e.expected)}`;
|
|
55557
|
+
}
|
|
55558
|
+
//#endregion
|
|
54410
55559
|
//#region server/tests/generate.ts
|
|
54411
55560
|
/**
|
|
54412
|
-
* Writes a Playwright test from a recorded flow: its steps
|
|
54413
|
-
*
|
|
55561
|
+
* Writes a Playwright test from a recorded flow: its steps and the checks the tester said, in the order they
|
|
55562
|
+
* happened. Page text only ever goes into strings and comments, escaped. Each step and each check is a test.step
|
|
55563
|
+
* numbered in the order it was written, so a run shows how far the test got, and which of them ran: a pass is only
|
|
55564
|
+
* believed when every one of them did. A secret is never written into the file: the test reads it from the
|
|
55565
|
+
* environment (SAYWRIGHT_SECRET_PASSWORD), where the studio puts what you saved for it.
|
|
54414
55566
|
*/
|
|
54415
55567
|
/** What a locator may call, as the bridge writes them: finding, narrowing to a card or row, picking one. */
|
|
54416
55568
|
var CALLS = /* @__PURE__ */ new Set([
|
|
@@ -54431,6 +55583,11 @@ var CALLS = /* @__PURE__ */ new Set([
|
|
|
54431
55583
|
var STRING = /^'(?:[^'\\\r\n\u2028\u2029]|\\[^\r\n\u2028\u2029])*'/;
|
|
54432
55584
|
var LITERAL = /^(?:-?\d+(?:\.\d+)?|true|false)/;
|
|
54433
55585
|
/**
|
|
55586
|
+
* A regular expression literal as the bridge writes one, for a name or a text with its numbers left open
|
|
55587
|
+
* (`/^Pay \$\d[\d.,]* and subscribe$/`): one token as JavaScript reads it, so nothing in it runs either.
|
|
55588
|
+
*/
|
|
55589
|
+
var REGEX = /^\/(?![*/])(?:[^/\\[\r\n\u2028\u2029]|\\[^\r\n\u2028\u2029]|\[(?:[^\]\\\r\n\u2028\u2029]|\\[^\r\n\u2028\u2029])*\])+\/[imsu]*/;
|
|
55590
|
+
/**
|
|
54434
55591
|
* Locators arrive as Playwright expressions built by the bridge, on one line; accept only that shape: `page`, then
|
|
54435
55592
|
* calls whose arguments are strings, numbers and flat objects of them. Page text can only be inside those strings,
|
|
54436
55593
|
* so whatever it says, nothing in it runs.
|
|
@@ -54451,7 +55608,7 @@ function isLocator(code) {
|
|
|
54451
55608
|
};
|
|
54452
55609
|
const value = (flat) => {
|
|
54453
55610
|
blank();
|
|
54454
|
-
if (token(STRING) !== null || token(LITERAL) !== null) return true;
|
|
55611
|
+
if (token(STRING) !== null || token(LITERAL) !== null || token(REGEX) !== null) return true;
|
|
54455
55612
|
if (flat || code[i] !== "{") return false;
|
|
54456
55613
|
i++;
|
|
54457
55614
|
for (;;) {
|
|
@@ -54492,45 +55649,162 @@ function testTitle(input) {
|
|
|
54492
55649
|
if (input.name?.trim()) return input.name.trim();
|
|
54493
55650
|
const e = input.expectations[0];
|
|
54494
55651
|
if (!e) return "narrated flow";
|
|
54495
|
-
return `${e.description.replace(/^the /i, "")}
|
|
55652
|
+
return `${e.description.replace(/^the /i, "")} ${shouldBe(e, plain)}`;
|
|
55653
|
+
}
|
|
55654
|
+
var plain = (s) => s;
|
|
55655
|
+
/** A secret's name as the test reads it: capitals, digits and underscores (PASSWORD, CARD_NUMBER). */
|
|
55656
|
+
var secretKey$1 = (name) => secretEnvName(name).slice(SECRET_ENV_PREFIX.length);
|
|
55657
|
+
/** A page's address without its origin, as `page.goto` takes it with the config's baseURL. */
|
|
55658
|
+
function pathOf(url) {
|
|
55659
|
+
try {
|
|
55660
|
+
const u = new URL(url, "http://app.invalid");
|
|
55661
|
+
return `${u.pathname}${u.search}${u.hash}` || "/";
|
|
55662
|
+
} catch {
|
|
55663
|
+
return "/";
|
|
55664
|
+
}
|
|
54496
55665
|
}
|
|
55666
|
+
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
55667
|
+
/**
|
|
55668
|
+
* What a page's address must look like to be the same page on another run: its path, with the parts that change
|
|
55669
|
+
* from one run to the next (numbers, ids) left open; the query and the hash are left out. A hash route (#/checkout)
|
|
55670
|
+
* is the path.
|
|
55671
|
+
*/
|
|
55672
|
+
function urlPattern(url) {
|
|
55673
|
+
let u;
|
|
55674
|
+
try {
|
|
55675
|
+
u = new URL(url, "http://app.invalid");
|
|
55676
|
+
} catch {
|
|
55677
|
+
return ".*";
|
|
55678
|
+
}
|
|
55679
|
+
const body = (u.hash.startsWith("#/") ? u.hash.slice(1).split("?")[0] : u.pathname).split("/").map((p) => {
|
|
55680
|
+
if (!p) return "";
|
|
55681
|
+
const plain = decodeURIComponentSafe(p);
|
|
55682
|
+
return /\d/.test(plain) && (/^\d+$/.test(plain) || /^[0-9a-f-]{8,}$/i.test(plain) || /^[\w-]{16,}$/.test(plain)) ? "[^/?#]+" : escapeRe(p);
|
|
55683
|
+
}).join("/").replace(/\/$/, "") || "";
|
|
55684
|
+
return u.hash.startsWith("#/") ? `^[^#]*#${body}/?(?:[?]|$)` : `^[a-z]+://[^/]+${body}/?(?:[?#]|$)`;
|
|
55685
|
+
}
|
|
55686
|
+
function decodeURIComponentSafe(s) {
|
|
55687
|
+
try {
|
|
55688
|
+
return decodeURIComponent(s);
|
|
55689
|
+
} catch {
|
|
55690
|
+
return s;
|
|
55691
|
+
}
|
|
55692
|
+
}
|
|
55693
|
+
/** How a step is done, as code. */
|
|
55694
|
+
function stepCode(step) {
|
|
55695
|
+
switch (step.kind) {
|
|
55696
|
+
case "click": return `await ${safeLocator(step.locator)}.click();`;
|
|
55697
|
+
case "fill": {
|
|
55698
|
+
const loc = safeLocator(step.locator);
|
|
55699
|
+
if (step.secret) return `await ${loc}.fill(secret(${jsString(secretKey$1(step.secret))}));`;
|
|
55700
|
+
if (step.checked !== void 0) return `await ${loc}.${step.checked ? "check" : "uncheck"}();`;
|
|
55701
|
+
if (step.select || step.values) return `await ${loc}.selectOption(${optionsCode(step)});`;
|
|
55702
|
+
return `await ${loc}.fill(${jsString(maskSecrets(step.value ?? ""))});`;
|
|
55703
|
+
}
|
|
55704
|
+
case "press": return `await ${safeLocator(step.locator)}.press(${jsString(step.key ?? "Enter")});`;
|
|
55705
|
+
case "goto": return step.url ? `await page.goto(${jsString(pathOf(step.url))});` : null;
|
|
55706
|
+
case "url": return step.url ? `await expect(page).toHaveURL(new RegExp(${jsString(urlPattern(step.url))}));` : null;
|
|
55707
|
+
default: return null;
|
|
55708
|
+
}
|
|
55709
|
+
}
|
|
55710
|
+
/**
|
|
55711
|
+
* The options a step chose, as selectOption takes them: by what each says ({ label: 'Express' }), or by its value
|
|
55712
|
+
* when what it says has numbers in it (a price changes, its value doesn't) or isn't known.
|
|
55713
|
+
*/
|
|
55714
|
+
function optionsCode(step) {
|
|
55715
|
+
const labels = step.select ?? [];
|
|
55716
|
+
const values = step.values ?? [];
|
|
55717
|
+
const one = (i) => {
|
|
55718
|
+
const label = labels[i] ?? "";
|
|
55719
|
+
const value = values[i];
|
|
55720
|
+
if (label && !/\d/.test(label)) return `{ label: ${jsString(label)} }`;
|
|
55721
|
+
if (value !== void 0 && value !== "") return jsString(value);
|
|
55722
|
+
return `{ label: ${jsString(label)} }`;
|
|
55723
|
+
};
|
|
55724
|
+
const count = Math.max(labels.length, values.length);
|
|
55725
|
+
if (count <= 1) return count ? one(0) : "[]";
|
|
55726
|
+
return `[${Array.from({ length: count }, (_, i) => one(i)).join(", ")}]`;
|
|
55727
|
+
}
|
|
55728
|
+
/**
|
|
55729
|
+
* A check, as code: text for most things, the value for a field, whether a checkbox is checked, the options chosen in
|
|
55730
|
+
* a list (one of them says it, however many are chosen).
|
|
55731
|
+
*/
|
|
55732
|
+
function checkCode(e) {
|
|
55733
|
+
const loc = safeLocator(e.locator);
|
|
55734
|
+
if (e.field === "checked") return `await expect(${loc}).${e.expected === "not checked" ? "not." : ""}toBeChecked();`;
|
|
55735
|
+
if (e.field === "value") return `await expect(${loc}).toHaveValue(new RegExp(${jsString(escapeRe(e.expected))}));`;
|
|
55736
|
+
if (e.field === "select") return `await expect(${loc}.locator('option:checked')).toContainText([${jsString(e.expected)}]);`;
|
|
55737
|
+
return `await expect(${loc}).toContainText(${jsString(e.expected)});`;
|
|
55738
|
+
}
|
|
55739
|
+
/** A step title: its number (what a run is checked against) and its words, on one line. */
|
|
55740
|
+
var title = (mark, words) => jsString(`[${mark}] ${words.replace(/\s+/g, " ").trim().slice(0, 160)}`);
|
|
54497
55741
|
function generateSpec(input, date = /* @__PURE__ */ new Date()) {
|
|
54498
|
-
const
|
|
54499
|
-
const
|
|
55742
|
+
const start = pathOf(input.startUrl);
|
|
55743
|
+
const secrets = [...new Set(input.steps.filter((s) => s.kind === "fill" && s.secret).map((s) => secretKey$1(s.secret)))];
|
|
54500
55744
|
const lines = [];
|
|
54501
55745
|
lines.push(`import { expect, test } from '@playwright/test';`, "");
|
|
54502
|
-
lines.push(`// Narrated with ${BRAND.name} on ${date.toISOString().slice(0, 10)}
|
|
55746
|
+
lines.push(`// Narrated with ${BRAND.name} on ${date.toISOString().slice(0, 10)}: the steps you took and the checks you said, in that order.`);
|
|
55747
|
+
lines.push(`// Each is a test.step, so a run shows how far it got. It starts from a fresh browser, with no cookies or storage.`);
|
|
55748
|
+
if (secrets.length) {
|
|
55749
|
+
lines.push(`// Secrets are never written here: it types ${secrets.join(", ")} from ${secrets.map((s) => secretEnvName(s)).join(", ")}`);
|
|
55750
|
+
lines.push(`// (${BRAND.name} sets the ones you saved in its Test tab; elsewhere, set them in the environment).`);
|
|
55751
|
+
lines.push(`function secret(name: string): string {`);
|
|
55752
|
+
lines.push(` const value = process.env[\`${SECRET_ENV_PREFIX}\${name}\`];`);
|
|
55753
|
+
lines.push(` if (!value) throw new Error(\`This test needs ${SECRET_ENV_PREFIX}\${name}: set it in the environment, or save it in ${BRAND.name}'s Test tab.\`);`);
|
|
55754
|
+
lines.push(` return value;`);
|
|
55755
|
+
lines.push(`}`);
|
|
55756
|
+
}
|
|
55757
|
+
lines.push("");
|
|
54503
55758
|
lines.push(`test(${jsString(testTitle(input))}, async ({ page }) => {`);
|
|
54504
|
-
|
|
54505
|
-
|
|
54506
|
-
|
|
54507
|
-
|
|
54508
|
-
|
|
54509
|
-
|
|
54510
|
-
|
|
54511
|
-
|
|
54512
|
-
|
|
54513
|
-
case "press":
|
|
54514
|
-
lines.push(` await ${safeLocator(step.locator)}.press(${jsString(step.key ?? "Enter")});`);
|
|
54515
|
-
break;
|
|
54516
|
-
case "goto":
|
|
54517
|
-
if (step.url) {
|
|
54518
|
-
const u = new URL(step.url);
|
|
54519
|
-
lines.push(` await page.goto(${jsString(`${u.pathname}${u.search}${u.hash}`)});`);
|
|
54520
|
-
}
|
|
54521
|
-
break;
|
|
54522
|
-
case "note": lines.push(` // ${jsComment(step.description)}`);
|
|
54523
|
-
}
|
|
55759
|
+
let n = 0;
|
|
55760
|
+
let k = 0;
|
|
55761
|
+
const step = (mark, words, body) => {
|
|
55762
|
+
lines.push(` await test.step(${title(mark, words)}, async () => {`);
|
|
55763
|
+
for (const b of body) lines.push(` ${b}`);
|
|
55764
|
+
lines.push(` });`);
|
|
55765
|
+
};
|
|
55766
|
+
step(String(++n), `Open ${start}`, [`await page.goto(${jsString(start)});`]);
|
|
55767
|
+
const checksAt = /* @__PURE__ */ new Map();
|
|
54524
55768
|
for (const e of input.expectations) {
|
|
54525
|
-
|
|
54526
|
-
|
|
55769
|
+
const at = Math.max(0, Math.min(input.steps.length, e.after ?? input.steps.length));
|
|
55770
|
+
checksAt.set(at, [...checksAt.get(at) ?? [], e]);
|
|
54527
55771
|
}
|
|
55772
|
+
const check = (e) => {
|
|
55773
|
+
const was = e.field === "checked" ? `it was ${e.actual || "not checked"}` : `it showed ${e.actual || "nothing"}`;
|
|
55774
|
+
step(`check ${++k}`, maskSecrets(`${e.description.replace(/^./, (c) => c.toUpperCase())} ${shouldBe(e, plain)}`), [`// ${jsComment(maskSecrets(`You said ${e.description} ${shouldBe(e, plain)} (${was}).`))}`, checkCode(e)]);
|
|
55775
|
+
};
|
|
55776
|
+
input.steps.forEach((s, i) => {
|
|
55777
|
+
for (const e of checksAt.get(i) ?? []) check(e);
|
|
55778
|
+
if (s.kind === "note") {
|
|
55779
|
+
lines.push(` // ${jsComment(maskSecrets(s.description))}`);
|
|
55780
|
+
return;
|
|
55781
|
+
}
|
|
55782
|
+
const code = stepCode(s);
|
|
55783
|
+
if (code) step(String(++n), maskSecrets(s.description), [code]);
|
|
55784
|
+
});
|
|
55785
|
+
for (const e of checksAt.get(input.steps.length) ?? []) check(e);
|
|
54528
55786
|
lines.push("});", "");
|
|
54529
55787
|
return lines.join("\n");
|
|
54530
55788
|
}
|
|
54531
|
-
|
|
55789
|
+
/** The secrets a test types (by name), as its code reads them. */
|
|
55790
|
+
function secretsOf(code) {
|
|
55791
|
+
return [...new Set(Array.from(code.matchAll(/\bsecret\('([A-Z0-9_]+)'\)/g), (m) => m[1]))];
|
|
55792
|
+
}
|
|
55793
|
+
/**
|
|
55794
|
+
* Write the test to `tests/saywright/`, never over a file of someone else's. `replace`: the test this one is a new
|
|
55795
|
+
* version of (written again after another check was added): it takes that file's place when the studio wrote it and
|
|
55796
|
+
* nobody changed it since, rather than leaving both to run.
|
|
55797
|
+
*/
|
|
55798
|
+
function writeSpec(project, input, replace) {
|
|
54532
55799
|
const code = generateSpec(input);
|
|
54533
55800
|
const dir = `tests/${BRAND.slug}`;
|
|
55801
|
+
if (replace && replace.path.startsWith(`${dir}/`) && /^[\w./-]+\.spec\.ts$/.test(replace.path) && !replace.path.includes("..") && project.exists(replace.path) && project.read(replace.path) === replace.code) {
|
|
55802
|
+
project.write(replace.path, code);
|
|
55803
|
+
return {
|
|
55804
|
+
path: replace.path,
|
|
55805
|
+
code
|
|
55806
|
+
};
|
|
55807
|
+
}
|
|
54534
55808
|
const base = slug(testTitle(input));
|
|
54535
55809
|
let rel = `${dir}/${base}.spec.ts`;
|
|
54536
55810
|
for (let i = 2; project.exists(rel); i++) rel = `${dir}/${base}-${i}.spec.ts`;
|
|
@@ -54542,19 +55816,18 @@ function writeSpec(project, input) {
|
|
|
54542
55816
|
};
|
|
54543
55817
|
}
|
|
54544
55818
|
//#endregion
|
|
54545
|
-
//#region
|
|
55819
|
+
//#region server/tests/run.ts
|
|
54546
55820
|
/**
|
|
54547
|
-
*
|
|
54548
|
-
*
|
|
55821
|
+
* Runs a generated test with Playwright, and says what the run proves. The test runs with a configuration of the
|
|
55822
|
+
* studio's own (kept with the studio's data, never in the app's code): Chromium, no retries, no web server of its
|
|
55823
|
+
* own (the app the studio has open is the one tested), a fresh browser, and a JSON report. The app's own Playwright
|
|
55824
|
+
* configuration can't change the verdict (its retries, its browsers, its other tests), but its setup can come along:
|
|
55825
|
+
* a `tests/saywright/setup.ts` runs first, to put the data the test needs in place.
|
|
55826
|
+
*
|
|
55827
|
+
* A pass is believed only when every step and check of the test reported that it ran and passed, on the first try,
|
|
55828
|
+
* and the test is the one it is compared with. A failure says where: at a check (the bug shows) or at an earlier
|
|
55829
|
+
* step (the test didn't get that far: it doesn't show the bug yet).
|
|
54549
55830
|
*/
|
|
54550
|
-
function focusOn(received, expected) {
|
|
54551
|
-
if (!received || !expected || received === expected) return received;
|
|
54552
|
-
const shape = expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\d+/g, "\\d+");
|
|
54553
|
-
if (shape === expected || !/\\d\+/.test(shape)) return received;
|
|
54554
|
-
return new RegExp(shape).exec(received)?.[0] ?? received;
|
|
54555
|
-
}
|
|
54556
|
-
//#endregion
|
|
54557
|
-
//#region server/tests/run.ts
|
|
54558
55831
|
var ANSI = /\x1b\[[0-9;]*m/g;
|
|
54559
55832
|
function playwrightCli(project) {
|
|
54560
55833
|
try {
|
|
@@ -54603,8 +55876,9 @@ function run(cmd, cwd, env, timeoutMs, onLine) {
|
|
|
54603
55876
|
});
|
|
54604
55877
|
});
|
|
54605
55878
|
}
|
|
55879
|
+
/** Every test's runs (retries included), in the order the report lists them. */
|
|
54606
55880
|
function collect(suite, out) {
|
|
54607
|
-
for (const spec of suite.specs ?? []) for (const t of spec.tests)
|
|
55881
|
+
for (const spec of suite.specs ?? []) for (const t of spec.tests) out.push(t.results);
|
|
54608
55882
|
for (const s of suite.suites ?? []) collect(s, out);
|
|
54609
55883
|
}
|
|
54610
55884
|
function parseFailure(message) {
|
|
@@ -54617,64 +55891,244 @@ function parseFailure(message) {
|
|
|
54617
55891
|
};
|
|
54618
55892
|
}
|
|
54619
55893
|
var NEEDS_BROWSER = /Executable doesn't exist|download new browsers|npx playwright install/i;
|
|
54620
|
-
|
|
54621
|
-
|
|
55894
|
+
/** A step or check of a generated test, by the mark its title starts with. */
|
|
55895
|
+
var MARK = /^\[(\d+|check \d+)\] /;
|
|
55896
|
+
/** The steps and checks a test has, as its code names them (in order). */
|
|
55897
|
+
function marksOf(code) {
|
|
55898
|
+
return Array.from(code.matchAll(/\btest\.step\('\[(\d+|check \d+)\] /g), (m) => m[1]);
|
|
55899
|
+
}
|
|
55900
|
+
/** A short fingerprint of a test's code: a later run compares it, to know the test is the same one. */
|
|
55901
|
+
var hashOf = (code) => createHash("sha256").update(code).digest("hex").slice(0, 16);
|
|
55902
|
+
/** The values of secrets, masked out of what a run says (its errors and output). */
|
|
55903
|
+
/** What a run says, without the saved secrets' values, and without any key or card number the page showed (it goes to the AI with Fix with …). */
|
|
55904
|
+
function masker(values) {
|
|
55905
|
+
const all = values.filter((v) => v.length >= 3).sort((a, b) => b.length - a.length);
|
|
55906
|
+
return (text) => text === void 0 ? text : maskSecrets(all.reduce((t, v) => t.split(v).join(MASK), text));
|
|
55907
|
+
}
|
|
55908
|
+
/**
|
|
55909
|
+
* What a run proves. `steps`: what the report says of each step and check; `marks`: what the test has. A pass with
|
|
55910
|
+
* a step or check missing from the report proves nothing about it.
|
|
55911
|
+
*/
|
|
55912
|
+
function judge(result, marks, mask) {
|
|
55913
|
+
const steps = (result.steps ?? []).filter((s) => MARK.test(s.title)).map((s) => ({
|
|
55914
|
+
title: mask(s.title),
|
|
55915
|
+
check: /^\[check /.test(s.title),
|
|
55916
|
+
ok: !s.error,
|
|
55917
|
+
error: s.error?.message ? mask(s.error.message.replace(ANSI, "").slice(0, 1200)) : void 0
|
|
55918
|
+
}));
|
|
55919
|
+
const seen = new Set(steps.map((s) => MARK.exec(s.title)[1]));
|
|
55920
|
+
const isCheck = (m) => m.startsWith("check ");
|
|
55921
|
+
const counts = {
|
|
55922
|
+
steps: marks.filter((m) => !isCheck(m)).length,
|
|
55923
|
+
checks: marks.filter(isCheck).length,
|
|
55924
|
+
stepsPassed: steps.filter((s) => !s.check && s.ok).length,
|
|
55925
|
+
checksPassed: steps.filter((s) => s.check && s.ok).length
|
|
55926
|
+
};
|
|
55927
|
+
if (result.status === "passed") {
|
|
55928
|
+
const missing = marks.filter((m) => !seen.has(m));
|
|
55929
|
+
if (!marks.length) return {
|
|
55930
|
+
status: "passed",
|
|
55931
|
+
verified: false,
|
|
55932
|
+
unverified: "This test has no numbered steps (an older version of Saywright wrote it, or it was written by hand), so a pass can’t show that each step and check ran. Generate it again.",
|
|
55933
|
+
steps,
|
|
55934
|
+
counts
|
|
55935
|
+
};
|
|
55936
|
+
if (!counts.checks) return {
|
|
55937
|
+
status: "passed",
|
|
55938
|
+
verified: false,
|
|
55939
|
+
unverified: "This test has no check, so a pass only shows its steps ran.",
|
|
55940
|
+
steps,
|
|
55941
|
+
counts
|
|
55942
|
+
};
|
|
55943
|
+
if (missing.length) return {
|
|
55944
|
+
status: "passed",
|
|
55945
|
+
verified: false,
|
|
55946
|
+
unverified: `The test passed, but ${missing.length === 1 ? "a step" : `${missing.length} steps`} of it didn’t report running (${missing.map((m) => `[${m}]`).join(", ")}).`,
|
|
55947
|
+
steps,
|
|
55948
|
+
counts
|
|
55949
|
+
};
|
|
55950
|
+
if ((result.retry ?? 0) > 0) return {
|
|
55951
|
+
status: "passed",
|
|
55952
|
+
verified: false,
|
|
55953
|
+
unverified: "The test passed only when tried again.",
|
|
55954
|
+
steps,
|
|
55955
|
+
counts
|
|
55956
|
+
};
|
|
55957
|
+
return {
|
|
55958
|
+
status: "passed",
|
|
55959
|
+
verified: true,
|
|
55960
|
+
steps,
|
|
55961
|
+
counts
|
|
55962
|
+
};
|
|
55963
|
+
}
|
|
55964
|
+
if (result.status === "skipped") return {
|
|
55965
|
+
status: "error",
|
|
55966
|
+
message: "The test was skipped, so it shows nothing.",
|
|
55967
|
+
steps,
|
|
55968
|
+
counts
|
|
55969
|
+
};
|
|
55970
|
+
const failing = steps.find((s) => !s.ok);
|
|
55971
|
+
const message = mask((failing?.error ?? result.error?.message ?? result.errors?.[0]?.message ?? result.status).replace(ANSI, ""));
|
|
55972
|
+
return {
|
|
55973
|
+
status: "failed",
|
|
55974
|
+
failedAt: failing?.check ? "check" : "step",
|
|
55975
|
+
message: message.slice(0, 1200),
|
|
55976
|
+
steps,
|
|
55977
|
+
counts
|
|
55978
|
+
};
|
|
55979
|
+
}
|
|
55980
|
+
/** A value as JavaScript source: the studio's configuration is written as a module with no imports. */
|
|
55981
|
+
var js = (v) => JSON.stringify(v);
|
|
55982
|
+
async function runSpec(project, rel, opts) {
|
|
55983
|
+
const file = project.resolve(rel);
|
|
54622
55984
|
const started = Date.now();
|
|
55985
|
+
let code;
|
|
55986
|
+
try {
|
|
55987
|
+
code = fs.readFileSync(file, "utf8");
|
|
55988
|
+
} catch {
|
|
55989
|
+
return {
|
|
55990
|
+
status: "error",
|
|
55991
|
+
durationMs: 0,
|
|
55992
|
+
message: `${rel} isn’t there any more.`
|
|
55993
|
+
};
|
|
55994
|
+
}
|
|
55995
|
+
const hash = hashOf(code);
|
|
54623
55996
|
const env = {
|
|
54624
55997
|
...process.env,
|
|
54625
|
-
SAYWRIGHT_TARGET_URL: baseUrl,
|
|
54626
|
-
FORCE_COLOR: "0"
|
|
54627
|
-
CI: "1"
|
|
55998
|
+
SAYWRIGHT_TARGET_URL: opts.baseUrl,
|
|
55999
|
+
FORCE_COLOR: "0"
|
|
54628
56000
|
};
|
|
54629
|
-
|
|
54630
|
-
|
|
54631
|
-
|
|
54632
|
-
|
|
54633
|
-
|
|
54634
|
-
|
|
54635
|
-
|
|
54636
|
-
|
|
54637
|
-
|
|
54638
|
-
|
|
54639
|
-
|
|
54640
|
-
durationMs,
|
|
54641
|
-
message: "Playwright needs its Chromium browser (one-time, about 150 MB)."
|
|
54642
|
-
};
|
|
54643
|
-
if (/Cannot find module '@playwright\/test'|Cannot find package '@playwright\/test'/.test(all)) return {
|
|
56001
|
+
delete env.CI;
|
|
56002
|
+
const values = [];
|
|
56003
|
+
const missingSecrets = [];
|
|
56004
|
+
for (const name of secretsOf(code)) {
|
|
56005
|
+
const v = opts.secrets?.[name] || process.env[secretEnvName(name)];
|
|
56006
|
+
if (v) {
|
|
56007
|
+
env[secretEnvName(name)] = v;
|
|
56008
|
+
values.push(v);
|
|
56009
|
+
} else missingSecrets.push(name);
|
|
56010
|
+
}
|
|
56011
|
+
if (missingSecrets.length) return {
|
|
54644
56012
|
status: "setup-needed",
|
|
54645
|
-
durationMs,
|
|
54646
|
-
|
|
56013
|
+
durationMs: 0,
|
|
56014
|
+
hash,
|
|
56015
|
+
missingSecrets,
|
|
56016
|
+
message: `This test types ${missingSecrets.join(", ")}. Save ${missingSecrets.length > 1 ? "them" : "it"} in the Test tab (kept on this computer only, never in the test), or set ${missingSecrets.map((n) => secretEnvName(n)).join(", ")}.`
|
|
54647
56017
|
};
|
|
54648
|
-
|
|
56018
|
+
const mask = masker(values);
|
|
56019
|
+
fs.mkdirSync(opts.workDir, {
|
|
56020
|
+
recursive: true,
|
|
56021
|
+
mode: 448
|
|
56022
|
+
});
|
|
56023
|
+
sweepRuns(opts.workDir);
|
|
56024
|
+
const id = `${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
|
56025
|
+
const configFile = path.join(opts.workDir, `run-${id}.config.mjs`);
|
|
56026
|
+
const reportFile = path.join(opts.workDir, `run-${id}.json`);
|
|
56027
|
+
const outputDir = path.join(opts.workDir, `run-${id}`);
|
|
56028
|
+
const setup = [
|
|
56029
|
+
"setup.ts",
|
|
56030
|
+
"setup.mjs",
|
|
56031
|
+
"setup.js"
|
|
56032
|
+
].map((f) => path.join(path.dirname(file), f)).find((f) => fs.existsSync(f));
|
|
56033
|
+
const chromium = process.env.PW_CHROMIUM_PATH;
|
|
56034
|
+
fs.writeFileSync(configFile, `// Written by Saywright for one run of ${js(rel)}; removed after it.
|
|
56035
|
+
export default {
|
|
56036
|
+
testDir: ${js(path.dirname(file))},
|
|
56037
|
+
testMatch: ${js(path.basename(file))},
|
|
56038
|
+
outputDir: ${js(outputDir)},
|
|
56039
|
+
retries: 0,
|
|
56040
|
+
workers: 1,
|
|
56041
|
+
timeout: 60000,
|
|
56042
|
+
expect: { timeout: 10000 },
|
|
56043
|
+
reporter: [['json', { outputFile: ${js(reportFile)} }]],
|
|
56044
|
+
${setup ? ` globalSetup: ${js(setup)},\n` : ""} use: {
|
|
56045
|
+
baseURL: ${js(opts.baseUrl)},
|
|
56046
|
+
browserName: 'chromium',
|
|
56047
|
+
actionTimeout: 15000,
|
|
56048
|
+
navigationTimeout: 30000,
|
|
56049
|
+
${chromium ? ` launchOptions: { executablePath: ${js(chromium)} },\n` : ""} },
|
|
56050
|
+
};
|
|
56051
|
+
`);
|
|
54649
56052
|
try {
|
|
54650
|
-
|
|
54651
|
-
|
|
54652
|
-
|
|
56053
|
+
const { code: exit, stdout, stderr } = await run([
|
|
56054
|
+
...playwrightCli(project),
|
|
56055
|
+
"test",
|
|
56056
|
+
"--config",
|
|
56057
|
+
configFile
|
|
56058
|
+
], project.root, env, 18e4);
|
|
56059
|
+
const durationMs = Date.now() - started;
|
|
56060
|
+
const all = mask(`${stdout}\n${stderr}`).replace(ANSI, "");
|
|
56061
|
+
if (NEEDS_BROWSER.test(all)) return {
|
|
56062
|
+
status: "setup-needed",
|
|
56063
|
+
durationMs,
|
|
56064
|
+
hash,
|
|
56065
|
+
message: "Playwright needs its Chromium browser (one-time, about 150 MB)."
|
|
56066
|
+
};
|
|
56067
|
+
if (/Cannot find module '@playwright\/test'|Cannot find package '@playwright\/test'/.test(all)) return {
|
|
56068
|
+
status: "setup-needed",
|
|
56069
|
+
durationMs,
|
|
56070
|
+
hash,
|
|
56071
|
+
message: `This project needs @playwright/test. Run: ${fs.existsSync(path.join(project.root, "pnpm-lock.yaml")) ? "pnpm add -D" : fs.existsSync(path.join(project.root, "yarn.lock")) ? "yarn add -D" : fs.existsSync(path.join(project.root, "bun.lockb")) || fs.existsSync(path.join(project.root, "bun.lock")) ? "bun add -d" : "npm i -D"} @playwright/test`
|
|
56072
|
+
};
|
|
56073
|
+
let report;
|
|
56074
|
+
try {
|
|
56075
|
+
report = JSON.parse(fs.readFileSync(reportFile, "utf8"));
|
|
56076
|
+
} catch {
|
|
56077
|
+
return {
|
|
56078
|
+
status: "error",
|
|
56079
|
+
durationMs,
|
|
56080
|
+
hash,
|
|
56081
|
+
message: "Couldn’t read the test results.",
|
|
56082
|
+
output: all.slice(-2e3)
|
|
56083
|
+
};
|
|
56084
|
+
}
|
|
56085
|
+
const runs = [];
|
|
56086
|
+
for (const s of report.suites ?? []) collect(s, runs);
|
|
56087
|
+
const first = runs.find((r) => r.length)?.[0];
|
|
56088
|
+
if (!first) return {
|
|
54653
56089
|
status: "error",
|
|
54654
56090
|
durationMs,
|
|
54655
|
-
|
|
54656
|
-
|
|
56091
|
+
hash,
|
|
56092
|
+
message: mask(report.errors?.[0]?.message?.replace(ANSI, "") ?? `Playwright exited with code ${exit}`).slice(0, 600)
|
|
56093
|
+
};
|
|
56094
|
+
const verdict = judge(first, marksOf(code), mask);
|
|
56095
|
+
const out = {
|
|
56096
|
+
durationMs,
|
|
56097
|
+
hash,
|
|
56098
|
+
...verdict
|
|
54657
56099
|
};
|
|
56100
|
+
if (verdict.status === "failed" && verdict.message) Object.assign(out, parseFailure(verdict.message));
|
|
56101
|
+
if (out.verified && opts.baseline && opts.baseline !== hash) {
|
|
56102
|
+
out.verified = false;
|
|
56103
|
+
out.unverified = "The test changed since it failed, so its passing doesn’t show the bug is fixed. Run it once more to judge the test as it is now.";
|
|
56104
|
+
}
|
|
56105
|
+
return out;
|
|
56106
|
+
} finally {
|
|
56107
|
+
for (const f of [configFile, reportFile]) fs.rmSync(f, { force: true });
|
|
56108
|
+
fs.rmSync(outputDir, {
|
|
56109
|
+
recursive: true,
|
|
56110
|
+
force: true
|
|
56111
|
+
});
|
|
56112
|
+
}
|
|
56113
|
+
}
|
|
56114
|
+
/** A run cut short (the studio stopped meanwhile) leaves its files: its configuration, and Playwright's notes, which hold the pages' addresses. Gone the next time. */
|
|
56115
|
+
var LEFT_OVER_MS = 18e5;
|
|
56116
|
+
function sweepRuns(dir) {
|
|
56117
|
+
let names;
|
|
56118
|
+
try {
|
|
56119
|
+
names = fs.readdirSync(dir).filter((n) => /^run-[a-z0-9]+-[0-9a-f]{6}(?:\.config\.mjs|\.json)?$/.test(n));
|
|
56120
|
+
} catch {
|
|
56121
|
+
return;
|
|
56122
|
+
}
|
|
56123
|
+
for (const n of names) {
|
|
56124
|
+
const p = path.join(dir, n);
|
|
56125
|
+
try {
|
|
56126
|
+
if (Date.now() - fs.statSync(p).mtimeMs > LEFT_OVER_MS) fs.rmSync(p, {
|
|
56127
|
+
recursive: true,
|
|
56128
|
+
force: true
|
|
56129
|
+
});
|
|
56130
|
+
} catch {}
|
|
54658
56131
|
}
|
|
54659
|
-
const results = [];
|
|
54660
|
-
for (const s of report.suites ?? []) collect(s, results);
|
|
54661
|
-
if (!results.length) return {
|
|
54662
|
-
status: "error",
|
|
54663
|
-
durationMs,
|
|
54664
|
-
message: (report.errors?.[0]?.message?.replace(ANSI, "") ?? `Playwright exited with code ${code}`).slice(0, 600)
|
|
54665
|
-
};
|
|
54666
|
-
const failed = results.find((r) => r.status !== "passed" && r.status !== "skipped");
|
|
54667
|
-
if (!failed) return {
|
|
54668
|
-
status: "passed",
|
|
54669
|
-
durationMs
|
|
54670
|
-
};
|
|
54671
|
-
const message = (failed.error?.message ?? failed.errors?.[0]?.message ?? failed.status).replace(ANSI, "");
|
|
54672
|
-
return {
|
|
54673
|
-
status: "failed",
|
|
54674
|
-
durationMs,
|
|
54675
|
-
message: message.slice(0, 1200),
|
|
54676
|
-
...parseFailure(message)
|
|
54677
|
-
};
|
|
54678
56132
|
}
|
|
54679
56133
|
async function installBrowser(project, onLine) {
|
|
54680
56134
|
const { code } = await run([
|
|
@@ -54685,6 +56139,57 @@ async function installBrowser(project, onLine) {
|
|
|
54685
56139
|
return code === 0;
|
|
54686
56140
|
}
|
|
54687
56141
|
//#endregion
|
|
56142
|
+
//#region server/tests/secrets.ts
|
|
56143
|
+
/**
|
|
56144
|
+
* The values of the secrets an app's tests type (PASSWORD, CARD_NUMBER…), saved when you choose to, so the studio
|
|
56145
|
+
* can run its tests and replay its sessions. Kept with the studio's data, outside every app's code (no AI tool and
|
|
56146
|
+
* no commit reaches them), in a file only you can read. A test never holds one: it reads SAYWRIGHT_SECRET_PASSWORD,
|
|
56147
|
+
* which the studio sets from here when it runs it.
|
|
56148
|
+
*/
|
|
56149
|
+
/** A secret's name as tests use it: PASSWORD, CARD_NUMBER. */
|
|
56150
|
+
var secretKey = (name) => secretEnvName(name).slice(SECRET_ENV_PREFIX.length);
|
|
56151
|
+
var TestSecrets = class {
|
|
56152
|
+
file;
|
|
56153
|
+
constructor(file) {
|
|
56154
|
+
this.file = file;
|
|
56155
|
+
}
|
|
56156
|
+
read() {
|
|
56157
|
+
const saved = readJson$1(this.file);
|
|
56158
|
+
const out = {};
|
|
56159
|
+
for (const [k, v] of Object.entries(saved ?? {})) if (typeof v === "string" && v) out[secretKey(k)] = v;
|
|
56160
|
+
return out;
|
|
56161
|
+
}
|
|
56162
|
+
/** The values, by name (only the studio's server ever reads them all). */
|
|
56163
|
+
all() {
|
|
56164
|
+
return this.read();
|
|
56165
|
+
}
|
|
56166
|
+
/** Which secrets have a value, saved here or in the environment the studio was started in. */
|
|
56167
|
+
names() {
|
|
56168
|
+
const environment = Object.keys(process.env).filter((k) => k.startsWith("SAYWRIGHT_SECRET_") && process.env[k]).map((k) => k.slice(SECRET_ENV_PREFIX.length));
|
|
56169
|
+
return {
|
|
56170
|
+
saved: Object.keys(this.read()).sort(),
|
|
56171
|
+
environment: environment.sort()
|
|
56172
|
+
};
|
|
56173
|
+
}
|
|
56174
|
+
/** Save a value (an empty one forgets it). */
|
|
56175
|
+
set(name, value) {
|
|
56176
|
+
const all = this.read();
|
|
56177
|
+
const key = secretKey(name);
|
|
56178
|
+
if (value) all[key] = value;
|
|
56179
|
+
else delete all[key];
|
|
56180
|
+
fs.mkdirSync(path.dirname(this.file), {
|
|
56181
|
+
recursive: true,
|
|
56182
|
+
mode: 448
|
|
56183
|
+
});
|
|
56184
|
+
writeFileAtomic(this.file, JSON.stringify(all, null, 2), { mode: 384 });
|
|
56185
|
+
}
|
|
56186
|
+
/** The value a replay types for this name: saved here, else from the environment. */
|
|
56187
|
+
get(name) {
|
|
56188
|
+
const key = secretKey(name);
|
|
56189
|
+
return this.read()[key] || process.env["SAYWRIGHT_SECRET_" + key] || void 0;
|
|
56190
|
+
}
|
|
56191
|
+
};
|
|
56192
|
+
//#endregion
|
|
54688
56193
|
//#region server/index.ts
|
|
54689
56194
|
var DEFAULT_SETTINGS = {};
|
|
54690
56195
|
async function discoverRoot(url) {
|
|
@@ -54841,6 +56346,7 @@ async function createStudioApi(layout) {
|
|
|
54841
56346
|
console.warn(`[saywright] ${targetUrl}: ${err instanceof Error ? err.message : err}`);
|
|
54842
56347
|
}
|
|
54843
56348
|
}
|
|
56349
|
+
apps.restoreRecordings();
|
|
54844
56350
|
const status = () => {
|
|
54845
56351
|
const w = apps.active();
|
|
54846
56352
|
const project = w?.project;
|
|
@@ -54889,6 +56395,20 @@ async function createStudioApi(layout) {
|
|
|
54889
56395
|
return w ? new Tracer(w.project, w.mapper) : null;
|
|
54890
56396
|
};
|
|
54891
56397
|
const toSource = async (src) => await tracer()?.toSource(src) ?? null;
|
|
56398
|
+
/** Where a component is used in the app's code (`<Card …`), its tests and stories left out; a quick look, at most USES_LIMIT. */
|
|
56399
|
+
const USES_LIMIT = 20;
|
|
56400
|
+
const usesOf = async (project, component) => {
|
|
56401
|
+
try {
|
|
56402
|
+
const found = await project.search(`<${component.replace(/\$/g, "\\$")}[\\s/>]`, {
|
|
56403
|
+
regex: true,
|
|
56404
|
+
limit: 40,
|
|
56405
|
+
budgetMs: 400
|
|
56406
|
+
});
|
|
56407
|
+
return Math.min(USES_LIMIT, found.hits.filter((h) => !/\.(test|spec|stories)\.[jt]sx?$/.test(h.path)).length);
|
|
56408
|
+
} catch {
|
|
56409
|
+
return 0;
|
|
56410
|
+
}
|
|
56411
|
+
};
|
|
54892
56412
|
/** Each ref's source location: as the page said (mapped from bundles), or found in the code by what identifies it (a guess). */
|
|
54893
56413
|
const mapRefs = async (raw, pageUrl) => {
|
|
54894
56414
|
const list = Array.isArray(raw) ? raw.slice(0, 6) : [];
|
|
@@ -54918,6 +56438,7 @@ async function createStudioApi(layout) {
|
|
|
54918
56438
|
sessionRoutes(router, {
|
|
54919
56439
|
active: () => apps.active(),
|
|
54920
56440
|
forSession: (id) => apps.forSession(id),
|
|
56441
|
+
recording: () => apps.all().find((w) => !!w.sessions.active()) ?? null,
|
|
54921
56442
|
speech,
|
|
54922
56443
|
saveSpeechChoice: (lang, id) => {
|
|
54923
56444
|
settings.speech = {
|
|
@@ -54961,10 +56482,19 @@ async function createStudioApi(layout) {
|
|
|
54961
56482
|
});
|
|
54962
56483
|
const current = ws();
|
|
54963
56484
|
const { project, changes, metrics } = current;
|
|
54964
|
-
context
|
|
56485
|
+
if (!brief) Object.assign(context, {
|
|
56486
|
+
said: text,
|
|
56487
|
+
saidApp: current.app.id,
|
|
56488
|
+
saidAt: Date.now()
|
|
56489
|
+
});
|
|
54965
56490
|
if (!project.hasCode) return addressOnly(current.app.name);
|
|
54966
56491
|
const quick = brief || body.ai === true ? null : quickEdit(text, refs, project);
|
|
54967
56492
|
if (quick?.kind === "edit") {
|
|
56493
|
+
const uses = quick.component ? await usesOf(project, quick.component) : 0;
|
|
56494
|
+
if (uses > 1) {
|
|
56495
|
+
if (!refs[0]?.same) quick.title += ` · ${quick.component} is used in ${uses}${uses >= USES_LIMIT ? "+" : ""} places`;
|
|
56496
|
+
quick.summary += ` ${quick.component} is used in ${uses}${uses >= USES_LIMIT ? " or more" : ""} places in your code: it changes in all of them.`;
|
|
56497
|
+
}
|
|
54968
56498
|
const change = await changes.preview({
|
|
54969
56499
|
title: quick.title,
|
|
54970
56500
|
summary: quick.summary,
|
|
@@ -55198,16 +56728,24 @@ async function createStudioApi(layout) {
|
|
|
55198
56728
|
guessed: true
|
|
55199
56729
|
} : result };
|
|
55200
56730
|
});
|
|
56731
|
+
/** The secrets this app's tests type, saved on this computer for runs and replays (with the studio's data, outside the app). */
|
|
56732
|
+
const secretsOf = (w) => new TestSecrets(path.join(dataDir, "secrets", `${w.app.id}.json`));
|
|
56733
|
+
/** Where a test run's configuration and report go: the studio's data, never the app's code. */
|
|
56734
|
+
const runsDir = (w) => path.join(dataDir, "test-runs", w.app.id);
|
|
55201
56735
|
router.post("/tests/generate", (req) => {
|
|
55202
56736
|
const body = req.body ?? {};
|
|
55203
56737
|
if (!Array.isArray(body.expectations) || !body.expectations.length) throw new HttpError(400, "Say what you expected first");
|
|
55204
56738
|
const { project, metrics } = ws();
|
|
56739
|
+
if (!project.hasCode) throw new HttpError(409, "A test is saved with your app’s code, and this app has none on this computer: link its folder to write tests.", "no-code");
|
|
55205
56740
|
const spec = writeSpec(project, {
|
|
55206
56741
|
name: body.name,
|
|
55207
56742
|
startUrl: typeof body.startUrl === "string" ? body.startUrl : project.url,
|
|
55208
|
-
steps: Array.isArray(body.steps) ? body.steps.slice(0, 100) : [],
|
|
56743
|
+
steps: sanitizeSteps(Array.isArray(body.steps) ? body.steps.slice(0, 100) : []),
|
|
55209
56744
|
expectations: body.expectations.slice(0, 20)
|
|
55210
|
-
}
|
|
56745
|
+
}, typeof body.replace?.path === "string" && typeof body.replace.code === "string" ? {
|
|
56746
|
+
path: body.replace.path,
|
|
56747
|
+
code: body.replace.code
|
|
56748
|
+
} : void 0);
|
|
55211
56749
|
metrics.record({
|
|
55212
56750
|
type: "test-generated",
|
|
55213
56751
|
path: spec.path,
|
|
@@ -55216,17 +56754,47 @@ async function createStudioApi(layout) {
|
|
|
55216
56754
|
return spec;
|
|
55217
56755
|
});
|
|
55218
56756
|
router.post("/tests/run", async (req) => {
|
|
55219
|
-
const
|
|
55220
|
-
const
|
|
55221
|
-
const
|
|
56757
|
+
const body = req.body ?? {};
|
|
56758
|
+
const rel = asString(body.path, "path", 300);
|
|
56759
|
+
const baseline = typeof body.baseline === "string" ? body.baseline.slice(0, 64) : void 0;
|
|
56760
|
+
const current = ws();
|
|
56761
|
+
const { project, metrics } = current;
|
|
56762
|
+
const result = await runSpec(project, rel, {
|
|
56763
|
+
baseUrl: project.url,
|
|
56764
|
+
workDir: runsDir(current),
|
|
56765
|
+
secrets: secretsOf(current).all(),
|
|
56766
|
+
baseline
|
|
56767
|
+
});
|
|
55222
56768
|
metrics.record({
|
|
55223
56769
|
type: "test",
|
|
55224
56770
|
path: rel,
|
|
55225
56771
|
status: result.status,
|
|
55226
|
-
durationMs: result.durationMs
|
|
56772
|
+
durationMs: result.durationMs,
|
|
56773
|
+
verified: result.verified ?? false
|
|
55227
56774
|
});
|
|
55228
56775
|
return result;
|
|
55229
56776
|
});
|
|
56777
|
+
router.get("/tests/secrets", () => secretsOf(ws()).names());
|
|
56778
|
+
router.put("/tests/secrets", (req) => {
|
|
56779
|
+
const body = req.body ?? {};
|
|
56780
|
+
const name = secretKey(asString(body.name, "name", 60));
|
|
56781
|
+
const value = body.value === void 0 ? "" : asString(body.value, "value", 4e3);
|
|
56782
|
+
const store = secretsOf(ws());
|
|
56783
|
+
store.set(name, value);
|
|
56784
|
+
return store.names();
|
|
56785
|
+
});
|
|
56786
|
+
router.post("/tests/secrets/values", (req) => {
|
|
56787
|
+
const names = req.body?.names;
|
|
56788
|
+
if (!Array.isArray(names)) throw new HttpError(400, "Expected { names }");
|
|
56789
|
+
const store = secretsOf(ws());
|
|
56790
|
+
const out = {};
|
|
56791
|
+
for (const n of names.slice(0, 20)) {
|
|
56792
|
+
if (typeof n !== "string") continue;
|
|
56793
|
+
const v = store.get(n);
|
|
56794
|
+
if (v) out[secretKey(n)] = v;
|
|
56795
|
+
}
|
|
56796
|
+
return { values: out };
|
|
56797
|
+
});
|
|
55230
56798
|
router.post("/tests/install", async () => {
|
|
55231
56799
|
return { ok: await installBrowser(ws().project, (line) => hub.emit("install", { line })) };
|
|
55232
56800
|
});
|
|
@@ -55255,13 +56823,14 @@ async function createStudioApi(layout) {
|
|
|
55255
56823
|
inflight.set(requestId, ctl);
|
|
55256
56824
|
const started = Date.now();
|
|
55257
56825
|
try {
|
|
56826
|
+
const testCode = project.read(rel);
|
|
55258
56827
|
const out = await agent.run({
|
|
55259
56828
|
text: text || "Fix the bug this test shows.",
|
|
55260
56829
|
refs: await mapRefs(body.refs),
|
|
55261
56830
|
mode: "fix",
|
|
55262
56831
|
test: {
|
|
55263
56832
|
path: rel,
|
|
55264
|
-
code:
|
|
56833
|
+
code: testCode,
|
|
55265
56834
|
failure
|
|
55266
56835
|
}
|
|
55267
56836
|
}, {
|
|
@@ -55271,13 +56840,33 @@ async function createStudioApi(layout) {
|
|
|
55271
56840
|
});
|
|
55272
56841
|
const latencyMs = Date.now() - started;
|
|
55273
56842
|
if (out.kind === "change") {
|
|
56843
|
+
const normalized = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
56844
|
+
const kept = out.edits.filter((e) => normalized(e.path) !== normalized(rel));
|
|
56845
|
+
const dropped = out.edits.length - kept.length;
|
|
56846
|
+
if (!kept.length) {
|
|
56847
|
+
metrics.record({
|
|
56848
|
+
type: "command",
|
|
56849
|
+
text: text || "fix it",
|
|
56850
|
+
input,
|
|
56851
|
+
result: "refused",
|
|
56852
|
+
source: agent.source,
|
|
56853
|
+
latencyMs,
|
|
56854
|
+
fix: true
|
|
56855
|
+
});
|
|
56856
|
+
return {
|
|
56857
|
+
kind: "message",
|
|
56858
|
+
tone: "warn",
|
|
56859
|
+
text: `${aiLabel()} only changed the test, not your app. A fix is checked against the test as you recorded it, so that change was left out. If the test itself is wrong, record it again or edit it yourself.`
|
|
56860
|
+
};
|
|
56861
|
+
}
|
|
56862
|
+
const summary = dropped ? `${out.summary}\n\nIt also changed the test it is checked against; that part was left out, so the test still checks what you recorded.` : out.summary;
|
|
55274
56863
|
const change = await changes.preview({
|
|
55275
56864
|
title: out.title,
|
|
55276
|
-
summary
|
|
56865
|
+
summary,
|
|
55277
56866
|
said: text || "fix it",
|
|
55278
56867
|
source: agent.source,
|
|
55279
56868
|
model,
|
|
55280
|
-
edits:
|
|
56869
|
+
edits: kept,
|
|
55281
56870
|
latencyMs
|
|
55282
56871
|
});
|
|
55283
56872
|
metrics.record({
|
|
@@ -55441,7 +57030,11 @@ async function createStudioApi(layout) {
|
|
|
55441
57030
|
context.pageUrl = pageUrl;
|
|
55442
57031
|
context.selection = selection;
|
|
55443
57032
|
context.pins = pins;
|
|
55444
|
-
if (typeof body.said === "string")
|
|
57033
|
+
if (typeof body.said === "string") Object.assign(context, {
|
|
57034
|
+
said: body.said.slice(0, 500),
|
|
57035
|
+
saidApp: context.app,
|
|
57036
|
+
saidAt: Date.now()
|
|
57037
|
+
});
|
|
55445
57038
|
context.at = Date.now();
|
|
55446
57039
|
return { ok: true };
|
|
55447
57040
|
});
|
|
@@ -55619,7 +57212,7 @@ async function createStudioApi(layout) {
|
|
|
55619
57212
|
}
|
|
55620
57213
|
/**
|
|
55621
57214
|
* Everything the studio serves besides its interface: the API, the script that makes apps pointable, the
|
|
55622
|
-
* accessibility checks, and the WebAssembly
|
|
57215
|
+
* accessibility checks, and the WebAssembly runtime for on-device speech. Vite (`npm run dev`) and the
|
|
55623
57216
|
* standalone server (`npx @saywright/studio`) mount the same list.
|
|
55624
57217
|
*/
|
|
55625
57218
|
function studioRoutes(studio, layout) {
|
|
@@ -55661,7 +57254,6 @@ function studioRoutes(studio, layout) {
|
|
|
55661
57254
|
return null;
|
|
55662
57255
|
}
|
|
55663
57256
|
};
|
|
55664
|
-
const mediapipe = devDir("@mediapipe/tasks-vision", "wasm");
|
|
55665
57257
|
const ort = devDir("onnxruntime-web/wasm");
|
|
55666
57258
|
const models = modelsDir(layout.dataDir);
|
|
55667
57259
|
const modelFiles = (req, res, next) => {
|
|
@@ -55672,7 +57264,7 @@ function studioRoutes(studio, layout) {
|
|
|
55672
57264
|
} catch {
|
|
55673
57265
|
return next();
|
|
55674
57266
|
}
|
|
55675
|
-
const stat = file.startsWith(models + path.sep) && /\.
|
|
57267
|
+
const stat = file.startsWith(models + path.sep) && /\.onnx$/.test(file) ? fs.statSync(file, { throwIfNoEntry: false }) : void 0;
|
|
55676
57268
|
if (!stat?.isFile()) return next();
|
|
55677
57269
|
res.setHeader("Content-Type", "application/octet-stream");
|
|
55678
57270
|
res.setHeader("Content-Length", stat.size);
|
|
@@ -55686,7 +57278,6 @@ function studioRoutes(studio, layout) {
|
|
|
55686
57278
|
["/saywright.js", bridgeScript],
|
|
55687
57279
|
["/axe.min.js", axe],
|
|
55688
57280
|
["/__open-in-editor", editorRoute(() => studio.apps.active()?.project ?? null)],
|
|
55689
|
-
...mediapipe ? [["/mediapipe/wasm", files(mediapipe)]] : [],
|
|
55690
57281
|
...ort ? [["/ort", files(ort)]] : []
|
|
55691
57282
|
];
|
|
55692
57283
|
}
|