@saywright/studio 0.1.0 → 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.
Files changed (75) hide show
  1. package/dist/THIRD_PARTY_NOTICES.md +0 -6
  2. package/dist/bridge.js +1109 -179
  3. package/dist/cli.mjs +1966 -375
  4. package/dist/client/assets/CommandPalette-VEQNxJ4K.js +1 -0
  5. package/dist/client/assets/CommandPalette-VEQNxJ4K.js.br +0 -0
  6. package/dist/client/assets/CommandPalette-VEQNxJ4K.js.gz +0 -0
  7. package/dist/client/assets/HelpDialog-CnCVSSNP.js +1 -0
  8. package/dist/client/assets/HelpDialog-CnCVSSNP.js.br +0 -0
  9. package/dist/client/assets/HelpDialog-CnCVSSNP.js.gz +0 -0
  10. package/dist/client/assets/ReportView-BwBy4JIG.js +4 -0
  11. package/dist/client/assets/ReportView-BwBy4JIG.js.br +0 -0
  12. package/dist/client/assets/ReportView-BwBy4JIG.js.gz +0 -0
  13. package/dist/client/assets/SettingsDialog-CS5w9CxJ.js +1 -0
  14. package/dist/client/assets/SettingsDialog-CS5w9CxJ.js.br +0 -0
  15. package/dist/client/assets/SettingsDialog-CS5w9CxJ.js.gz +0 -0
  16. package/dist/client/assets/arrow-left-BpsxETMA.js +1 -0
  17. package/dist/client/assets/brand-BSL9G2NW.js +57 -0
  18. package/dist/client/assets/brand-BSL9G2NW.js.br +0 -0
  19. package/dist/client/assets/brand-BSL9G2NW.js.gz +0 -0
  20. package/dist/client/assets/footprints-Bc2dzReV.js +1 -0
  21. package/dist/client/assets/index-BrM0s_jp.css +2 -0
  22. package/dist/client/assets/index-BrM0s_jp.css.br +0 -0
  23. package/dist/client/assets/index-BrM0s_jp.css.gz +0 -0
  24. package/dist/client/assets/index-C3xqQUPf.js +19 -0
  25. package/dist/client/assets/index-C3xqQUPf.js.br +0 -0
  26. package/dist/client/assets/index-C3xqQUPf.js.gz +0 -0
  27. package/dist/client/assets/refresh-cw-DocKnJID.js +1 -0
  28. package/dist/client/index.html +3 -5
  29. package/dist/client/index.html.br +0 -0
  30. package/dist/client/index.html.gz +0 -0
  31. package/dist/sample/package.json +1 -1
  32. package/dist/sample/src/App.tsx +3 -2
  33. package/dist/sample/src/lib/route.ts +3 -2
  34. package/dist/sample/src/pages/Account.tsx +88 -0
  35. package/dist/sample/src/styles.css +5 -0
  36. package/package.json +1 -2
  37. package/dist/client/assets/Calibration-xY4YDLzU.js +0 -1
  38. package/dist/client/assets/Calibration-xY4YDLzU.js.br +0 -0
  39. package/dist/client/assets/Calibration-xY4YDLzU.js.gz +0 -0
  40. package/dist/client/assets/CommandPalette-BA3akF-p.js +0 -1
  41. package/dist/client/assets/CommandPalette-BA3akF-p.js.br +0 -0
  42. package/dist/client/assets/CommandPalette-BA3akF-p.js.gz +0 -0
  43. package/dist/client/assets/HelpDialog-C6qmdEwi.js +0 -1
  44. package/dist/client/assets/HelpDialog-C6qmdEwi.js.br +0 -0
  45. package/dist/client/assets/HelpDialog-C6qmdEwi.js.gz +0 -0
  46. package/dist/client/assets/ReportView-Bj9xxLCi.js +0 -4
  47. package/dist/client/assets/ReportView-Bj9xxLCi.js.br +0 -0
  48. package/dist/client/assets/ReportView-Bj9xxLCi.js.gz +0 -0
  49. package/dist/client/assets/SettingsDialog-VrACcdUX.js +0 -1
  50. package/dist/client/assets/SettingsDialog-VrACcdUX.js.br +0 -0
  51. package/dist/client/assets/SettingsDialog-VrACcdUX.js.gz +0 -0
  52. package/dist/client/assets/button-B2NFEpCs.js +0 -18
  53. package/dist/client/assets/button-B2NFEpCs.js.br +0 -0
  54. package/dist/client/assets/button-B2NFEpCs.js.gz +0 -0
  55. package/dist/client/assets/camera-BD9K1f5I.js +0 -1
  56. package/dist/client/assets/confirm-D1U-bFIL.js +0 -41
  57. package/dist/client/assets/confirm-D1U-bFIL.js.br +0 -0
  58. package/dist/client/assets/confirm-D1U-bFIL.js.gz +0 -0
  59. package/dist/client/assets/footprints-DiYtIryA.js +0 -1
  60. package/dist/client/assets/index-BakNwOFF.css +0 -2
  61. package/dist/client/assets/index-BakNwOFF.css.br +0 -0
  62. package/dist/client/assets/index-BakNwOFF.css.gz +0 -0
  63. package/dist/client/assets/index-DUAtV_Av.js +0 -18
  64. package/dist/client/assets/index-DUAtV_Av.js.br +0 -0
  65. package/dist/client/assets/index-DUAtV_Av.js.gz +0 -0
  66. package/dist/client/assets/preload-helper-BLdHbfdN.js +0 -1
  67. package/dist/client/assets/preload-helper-BLdHbfdN.js.br +0 -0
  68. package/dist/client/assets/preload-helper-BLdHbfdN.js.gz +0 -0
  69. package/dist/client/assets/refresh-cw-Dobx2YmO.js +0 -1
  70. package/dist/client/assets/tracker.worker-DWwj76VA.js +0 -41
  71. package/dist/client/assets/tracker.worker-DWwj76VA.js.br +0 -0
  72. package/dist/client/assets/tracker.worker-DWwj76VA.js.gz +0 -0
  73. package/dist/client/assets/vision_bundle-ByYCzljp.js +0 -41
  74. package/dist/client/assets/vision_bundle-ByYCzljp.js.br +0 -0
  75. 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) await attempt("Hand model", () => ensureHandModel(dataDir), "Hand control loads it from Google’s servers instead.");
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
- const first = relative.split(path.sep)[0];
15237
- if (IGNORED_DIRS$1.has(first) || relative.split(path.sep).some((p) => p === "node_modules" || p === ".git")) throw new HttpError(400, `Path is not editable: ${rel}`);
15238
- if (/(^|\/)\.env/.test(clean)) throw new HttpError(400, "Environment files are off limits");
15239
- if (!this.contains(abs)) throw new HttpError(400, `Path is outside the project: ${rel}`);
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
- * Whether a path stays inside the project with every link along it followed. A file that doesn't exist yet is
15245
- * judged by the folder it would be written in; a link that leads nowhere is refused (writing would create its target).
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
- contains(abs) {
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 false;
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 false;
15592
+ return null;
15265
15593
  }
15266
15594
  const target = path.join(real, ...rest);
15267
- return [this.realRoot, this.workspace && fs.realpathSync(this.workspace)].some((root) => {
15595
+ for (const root of [this.realRoot, this.workspace && fs.realpathSync(this.workspace)]) {
15268
15596
  const relative = root ? path.relative(root, target) : "";
15269
- return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
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 abs = this.resolve(rel);
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(abs));
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
- /** The proxy's path for another port of this computer: /__saywright/<http|https>/<host>:<port><path>. */
16139
- var VIA = /^\/__saywright\/(https?)\/(localhost|127\.0\.0\.1|\[::1\]):(\d{1,5})(\/.*)?$/s;
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[3]);
16149
- const rest = m[4] ?? "/";
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 new URL(`${m[1]}://${m[2]}:${port}${rest}`);
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 url = viaUrl(req.url, target.upstream, this.refused());
16332
- if (!url || !fromPage(req.headers, proxyOrigin)) {
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 upstreamReq = (secure ? https : http).request(url, {
16343
- method: req.method,
16344
- headers,
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
- ...secure ? {
16347
- rejectUnauthorized: false,
16348
- servername: url.hostname === "localhost" ? "localhost" : void 0
16349
- } : {}
16816
+ ...tlsOptions
16350
16817
  }, (up) => {
16351
- const out = {};
16352
- for (const [k, v] of Object.entries(up.headers)) {
16353
- if (v === void 0 || HOP.has(k) || DROP_RESPONSE.has(k)) continue;
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
- upstreamReq.on("timeout", () => upstreamReq.destroy(/* @__PURE__ */ new Error("timed out")));
16362
- upstreamReq.on("error", () => res.destroy());
16363
- req.pipe(upstreamReq);
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 (connection === "plugin") connection = "proxy";
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
- /** Whether a call may go ahead. In "stop" mode, not once a limit is reached. */
42367
- allow(now = Date.now()) {
42368
- const status = this.status(now);
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: !(status.level === "over" && this.state.budget.mode === "stop"),
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": return `Typed “${action.value ?? ""}” into ${name}`;
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(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;|&apos;/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
- * Removing (or moving) a whole child of other markup, with only the blank space of its own lines, leaves valid
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 s.toString();
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, pointing with the hand or the mouse), the
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"”'’.!?]+$/g, "");
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
- /** The new text a rename asks for, and whether it is for every place it appears; null when more is asked of it than a rename. */
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[0].toUpperCase() + found.value.slice(1),
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
- const where = locOf(a);
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
- const found = findElement(code, where.loc, ast);
47912
- if (!found) return null;
47913
- const name = elementName(found.el);
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(found.el);
48788
+ const props = readProps(el);
47917
48789
  const what = describeRef(a);
47918
- try {
47919
- switch (intent.kind) {
47920
- case "set": {
47921
- if (!spec) return null;
47922
- const pick = pickSet(spec, intent.terms, config.synonyms);
47923
- if (!pick) return null;
47924
- const def = spec.props[pick.prop];
47925
- if (current(props, pick.prop, def) === pick.value) return {
47926
- kind: "message",
47927
- tone: "info",
47928
- text: `${what} already uses ${pick.prop} “${pick.value}”.`
47929
- };
47930
- const after = setProp(code, where.loc, pick.prop, valueOrNull(def, pick.value), ast);
47931
- const was = current(props, pick.prop, def);
47932
- return edit(`${what} · ${pick.prop} ${was ? `${was} → ` : "→ "}${pick.value}`, `Set ${pick.prop}="${pick.value}" on ${name} in ${where.file}.`, where.file, code, after);
47933
- }
47934
- case "step": {
47935
- if (!spec) return null;
47936
- const chosen = stepProp(spec, intent.focus);
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} is already the ${intent.dir > 0 ? "largest" : "smallest"} ${prop} (“${now}”) in your design system.`,
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
- const after = setProp(code, where.loc, prop, valueOrNull(def, next), ast);
47958
- return edit(`${what} · ${prop} ${now} → ${next}`, `Changed ${prop} from "${now}" to "${next}" on ${name}.`, where.file, code, after);
47959
- }
47960
- case "copy": {
47961
- const b = refs[1];
47962
- const bWhere = locOf(b);
47963
- if (!b || !bWhere) return {
47964
- kind: "message",
47965
- tone: "info",
47966
- text: "Point at a second thing for “that”, or select both first (hold a pinch, or ⇧ click, on each)."
47967
- };
47968
- if (!spec) return null;
47969
- const bCode = bWhere.file === where.file ? code : project.read(bWhere.file);
47970
- const bFound = findElement(bCode, bWhere.loc, bCode === code ? ast : void 0);
47971
- if (!bFound) return null;
47972
- if (elementName(bFound.el) !== name) return null;
47973
- const bProps = readProps(bFound.el);
47974
- const sets = [];
47975
- const changed = [];
47976
- for (const [prop, def] of Object.entries(spec.props)) {
47977
- let target = current(bProps, prop, def);
47978
- if (target === null) continue;
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
- if (current(props, prop, def) === target) continue;
47985
- sets.push([prop, valueOrNull(def, target)]);
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
- if (!changed.length) return {
47989
- kind: "message",
47990
- tone: "info",
47991
- text: `${what} already looks like ${describeRef(b)}.`
47992
- };
47993
- const after = setProps(code, where.loc, sets, ast);
47994
- const tail = intent.adjust < 0 ? ", but smaller" : intent.adjust > 0 ? ", but bigger" : "";
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
- } catch (err) {
48035
- if (err instanceof CodemodError) return null;
48036
- throw err;
48923
+ return null;
48037
48924
  }
48038
- return null;
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) steps.push({
49065
- t: at,
49066
- kind: "fill",
49067
- locator: e.el.locator,
49068
- value: e.masked ? "" : e.value,
49069
- description: `Fill ${nameOf(e.el)}`,
49070
- descriptionAr: `املأ ${nameOfAr(e.el)}`,
49071
- ...likeOf(e.el)
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
- if (sameOrigin(req.raw)) return;
50871
- if (!holdsKey(req, of(req.params.id), req.params.id)) throw new HttpError(403, "This page can’t write to that session.");
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 ws = deps.active();
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, { name }) };
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"), JSON.stringify(r, null, 1));
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`), JSON.stringify(r, null, 1));
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
- out[o.id] = unknown(`The replay stopped before this: ${failed.description} (${replayReason(failed.reason, "en")}).`, `توقّفت الإعادة قبل هذا: ${failed.descriptionAr ?? failed.description} (${replayReason(failed.reason, "ar")}).`);
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" || m.status === "processing") this.finish(m.id, "Recovered after a restart");
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
- this.keys.set(meta.id, randomBytes(16).toString("hex"));
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
- if (c?.said) lines.push("", `Last said in the studio: “${c.said}”`);
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, and what the tester
54413
- * said should show. Page text only ever goes into strings and comments, escaped.
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, "")} should show ${e.expected}`;
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 url = new URL(input.startUrl);
54499
- const start = `${url.pathname}${url.search}${url.hash}` || "/";
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)}. Expectations come from what was said.`);
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
- lines.push(` await page.goto(${jsString(start)});`);
54505
- for (const step of input.steps) switch (step.kind) {
54506
- case "click":
54507
- lines.push(` await ${safeLocator(step.locator)}.click();`);
54508
- break;
54509
- case "fill":
54510
- if (step.checked !== void 0) lines.push(` await ${safeLocator(step.locator)}.${step.checked ? "check" : "uncheck"}();`);
54511
- else lines.push(` await ${safeLocator(step.locator)}.fill(${jsString(step.value ?? "")});`);
54512
- break;
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
- lines.push(` // ${jsComment(`You said ${e.description} should show ${e.expected} (it showed ${e.actual || "nothing"}).`)}`);
54526
- lines.push(` await expect(${safeLocator(e.locator)}).toContainText(${jsString(e.expected)});`);
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
- function writeSpec(project, input) {
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 shared/text.ts
55819
+ //#region server/tests/run.ts
54546
55820
  /**
54547
- * A check on a whole row receives "Total$78.40" when "$80.80" was expected:
54548
- * pick out the part that has the same shape as the expectation.
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) if (t.results.length) out.push(t.results[t.results.length - 1]);
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
- async function runSpec(project, rel, baseUrl) {
54621
- project.resolve(rel);
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
- const { code, stdout, stderr } = await run([
54630
- ...playwrightCli(project),
54631
- "test",
54632
- rel,
54633
- "--reporter=json",
54634
- "--workers=1"
54635
- ], project.root, env, 12e4);
54636
- const durationMs = Date.now() - started;
54637
- const all = `${stdout}\n${stderr}`;
54638
- if (NEEDS_BROWSER.test(all)) return {
54639
- status: "setup-needed",
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
- 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`
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
- let report;
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
- report = JSON.parse(stdout.slice(stdout.indexOf("{")));
54651
- } catch {
54652
- return {
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
- message: "Couldn’t read the test results.",
54656
- output: all.replace(ANSI, "").slice(-2e3)
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.said = text;
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 rel = asString(req.body?.path, "path", 300);
55220
- const { project, metrics } = ws();
55221
- const result = await runSpec(project, rel, project.url);
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: project.read(rel),
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: out.summary,
56865
+ summary,
55277
56866
  said: text || "fix it",
55278
56867
  source: agent.source,
55279
56868
  model,
55280
- edits: out.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") context.said = body.said.slice(0, 500);
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 runtimes for hand tracking and speech. Vite (`npm run dev`) and the
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) && /\.(task|onnx)$/.test(file) ? fs.statSync(file, { throwIfNoEntry: false }) : void 0;
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
  }