qualty 0.1.8 → 0.1.9

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 (2) hide show
  1. package/bin/qualty.js +419 -23
  2. package/package.json +3 -2
package/bin/qualty.js CHANGED
@@ -1,15 +1,108 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { appendFileSync } from "node:fs";
3
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
5
8
  import process from "node:process";
6
9
  import { runLocalCi } from "./local-runner.js";
7
10
 
8
- /** When unset, CLI uses Qualty development API. Override for production: QUALTY_API_URL=https://qualty-api-production.up.railway.app or local: http://localhost:8000 */
11
+ /** CLI backend URL is fixed by Qualty distribution. */
9
12
  const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
13
+ const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
14
+ const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
15
+ const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
16
+ const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
17
+ const QUALTY_PROJECT_CONFIG_PATH = join(process.cwd(), ".qualty.json");
10
18
 
11
- function resolveApiUrl(args) {
12
- return String(args.api || process.env.QUALTY_API_URL || DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
19
+ function readQualtyConfig() {
20
+ if (!existsSync(QUALTY_CONFIG_PATH)) return {};
21
+ try {
22
+ const raw = readFileSync(QUALTY_CONFIG_PATH, "utf8");
23
+ const parsed = JSON.parse(raw);
24
+ if (!parsed || typeof parsed !== "object") return {};
25
+ return parsed;
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeQualtyConfig(config) {
32
+ mkdirSync(QUALTY_CONFIG_DIR, { recursive: true });
33
+ writeFileSync(QUALTY_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
34
+ try {
35
+ chmodSync(QUALTY_CONFIG_PATH, 0o600);
36
+ } catch {
37
+ // best effort on non-posix systems
38
+ }
39
+ }
40
+
41
+ function readSharedCredentials() {
42
+ if (!existsSync(QUALTY_SHARED_CREDS_PATH)) return {};
43
+ try {
44
+ const raw = readFileSync(QUALTY_SHARED_CREDS_PATH, "utf8");
45
+ const parsed = JSON.parse(raw);
46
+ if (!parsed || typeof parsed !== "object") return {};
47
+ return parsed;
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ function writeSharedCredentials(token) {
54
+ mkdirSync(QUALTY_SHARED_CREDS_DIR, { recursive: true });
55
+ writeFileSync(QUALTY_SHARED_CREDS_PATH, `${JSON.stringify({ api_token: token }, null, 2)}\n`, "utf8");
56
+ try {
57
+ chmodSync(QUALTY_SHARED_CREDS_PATH, 0o600);
58
+ } catch {
59
+ // best effort on non-posix systems
60
+ }
61
+ }
62
+
63
+ function readProjectConfig() {
64
+ if (!existsSync(QUALTY_PROJECT_CONFIG_PATH)) return {};
65
+ try {
66
+ const raw = readFileSync(QUALTY_PROJECT_CONFIG_PATH, "utf8");
67
+ const parsed = JSON.parse(raw);
68
+ if (!parsed || typeof parsed !== "object") return {};
69
+ return parsed;
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+
75
+ function writeProjectConfig(updates) {
76
+ const current = readProjectConfig();
77
+ const next = { ...current };
78
+ for (const [k, v] of Object.entries(updates || {})) {
79
+ if (v === undefined || v === null || v === "") delete next[k];
80
+ else next[k] = v;
81
+ }
82
+ writeFileSync(QUALTY_PROJECT_CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf8");
83
+ }
84
+
85
+ function resolveApiUrl(args, config = {}) {
86
+ void args;
87
+ void config;
88
+ return String(DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
89
+ }
90
+
91
+ function resolveToken(args, config = {}) {
92
+ const creds = readSharedCredentials();
93
+ return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || config.api_token || "";
94
+ }
95
+
96
+ function resolveOrgId(args, config = {}) {
97
+ const projectCfg = readProjectConfig();
98
+ return args.org || process.env.QUALTY_ORG_ID || config.org_id || projectCfg.default_org_id || "";
99
+ }
100
+
101
+ function maskSecret(secret) {
102
+ const s = String(secret || "");
103
+ if (!s) return "(empty)";
104
+ if (s.length <= 8) return `${"*".repeat(Math.max(0, s.length - 2))}${s.slice(-2)}`;
105
+ return `${s.slice(0, 4)}...${s.slice(-4)}`;
13
106
  }
14
107
 
15
108
  function parseArgs(argv) {
@@ -39,17 +132,17 @@ function usage() {
39
132
  console.log(
40
133
  [
41
134
  "Usage:",
42
- " qualty connect --project <project-id> [--port 3000] [--api https://your-api] [--token <bearer-token>] [--org <org-id>]",
43
- " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--api https://your-api] [--token <bearer-token>] [--org <org-id>]",
135
+ " qualty setup [--token <bearer-token>] [--org <org-id>]",
136
+ " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
137
+ " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
44
138
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
45
139
  " [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
46
140
  "",
47
141
  " After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
48
142
  " Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
49
- " qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--api https://your-api] [--token <bearer-token>]",
143
+ " qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--token <bearer-token>]",
50
144
  "",
51
145
  "Env vars:",
52
- ` QUALTY_API_URL Backend API URL (default: ${DEFAULT_QUALTY_API_URL}; set for local/self-hosted)`,
53
146
  " QUALTY_API_TOKEN Bearer token used for auth",
54
147
  " QUALTY_ORG_ID Org context for scoped API requests",
55
148
  " QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
@@ -99,11 +192,12 @@ function startCloudflared(token, port) {
99
192
  }
100
193
 
101
194
  async function runConnect(args) {
195
+ const config = readQualtyConfig();
102
196
  const projectId = args.project;
103
197
  const port = Number(args.port || 3000);
104
- const apiUrl = resolveApiUrl(args);
105
- const token = args.token || process.env.QUALTY_API_TOKEN;
106
- const orgId = args.org || process.env.QUALTY_ORG_ID;
198
+ const apiUrl = resolveApiUrl(args, config);
199
+ const token = resolveToken(args, config);
200
+ const orgId = resolveOrgId(args, config);
107
201
 
108
202
  if (!projectId || !token) {
109
203
  usage();
@@ -174,6 +268,163 @@ async function runConnect(args) {
174
268
  process.on("SIGTERM", shutdown);
175
269
  }
176
270
 
271
+ async function runSetup(args) {
272
+ const existing = readQualtyConfig();
273
+ const sharedCreds = readSharedCredentials();
274
+ const projectCfg = readProjectConfig();
275
+ const rl = createInterface({
276
+ input: process.stdin,
277
+ output: process.stdout,
278
+ });
279
+ const hasStdin = process.stdin.isTTY;
280
+ const ask = async (prompt, fallback = "") => {
281
+ if (!hasStdin) return fallback;
282
+ const answer = await rl.question(prompt);
283
+ const v = String(answer || "").trim();
284
+ return v || fallback;
285
+ };
286
+ const askYesNo = async (prompt, defaultValue = false) => {
287
+ if (!hasStdin) return defaultValue;
288
+ const answer = String(await rl.question(prompt)).trim();
289
+ if (!answer) return defaultValue;
290
+ const raw = answer.toLowerCase();
291
+ if (["y", "yes"].includes(raw)) return true;
292
+ if (["n", "no"].includes(raw)) return false;
293
+ return defaultValue;
294
+ };
295
+ const askChoice = async (prompt, min, max, defaultValue) => {
296
+ if (!hasStdin) return defaultValue;
297
+ while (true) {
298
+ const answer = String(await rl.question(prompt)).trim();
299
+ if (!answer) return defaultValue;
300
+ const n = Number(answer);
301
+ if (Number.isInteger(n) && n >= min && n <= max) return n;
302
+ // eslint-disable-next-line no-console
303
+ console.log(`[qualty] Please enter a number from ${min} to ${max}.`);
304
+ }
305
+ };
306
+
307
+ try {
308
+ const currentOrgId = String(args.org || existing.org_id || projectCfg.default_org_id || "");
309
+ const currentToken = String(args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || existing.api_token || "");
310
+
311
+ let token = "";
312
+ if (args.token) {
313
+ token = String(args.token).trim();
314
+ } else if (currentToken) {
315
+ const updateToken = await askYesNo(`API token already set (${maskSecret(currentToken)}). Update it? [y/N]: `, false);
316
+ if (updateToken) {
317
+ token = String(await ask("New Qualty API token [required]: ", "")).trim();
318
+ } else {
319
+ token = currentToken;
320
+ }
321
+ } else {
322
+ token = String(await ask("Qualty API token [required]: ", "")).trim();
323
+ }
324
+
325
+ if (!token) {
326
+ throw new Error("API token is required. Re-run setup and provide QUALTY_API_TOKEN.");
327
+ }
328
+
329
+ let organizations = [];
330
+ let currentOrgName = "";
331
+ try {
332
+ const orgResp = await apiRequest({
333
+ apiUrl: resolveApiUrl({}, {}),
334
+ token,
335
+ orgId: "",
336
+ path: "/api/v1/orgs",
337
+ });
338
+ organizations = Array.isArray(orgResp?.organizations) ? orgResp.organizations : [];
339
+ const byId = new Map(
340
+ organizations
341
+ .filter((o) => o && typeof o === "object")
342
+ .map((o) => [String(o.id || ""), String(o.name || "").trim()])
343
+ );
344
+ currentOrgName = byId.get(currentOrgId) || "";
345
+ } catch {
346
+ organizations = [];
347
+ }
348
+
349
+ let orgId = "";
350
+ let orgName = "";
351
+ if (args.org) {
352
+ orgId = String(args.org).trim();
353
+ orgName = currentOrgName || "selected organization";
354
+ } else if (organizations.length > 0) {
355
+ const orgRows = organizations
356
+ .map((o) => ({
357
+ id: String(o?.id || ""),
358
+ name: String(o?.name || "").trim() || "Unnamed org",
359
+ }))
360
+ .filter((o) => o.id);
361
+ const currentIndex = orgRows.findIndex((o) => o.id === currentOrgId);
362
+ if (currentIndex >= 0) {
363
+ const updateOrg = await askYesNo(
364
+ `Organization already set (${orgRows[currentIndex].name}). Update it? [y/N]: `,
365
+ false
366
+ );
367
+ if (!updateOrg) {
368
+ orgId = orgRows[currentIndex].id;
369
+ orgName = orgRows[currentIndex].name;
370
+ }
371
+ }
372
+ if (!orgId) {
373
+ // eslint-disable-next-line no-console
374
+ console.log("[qualty] Choose organization:");
375
+ for (let i = 0; i < orgRows.length; i += 1) {
376
+ // eslint-disable-next-line no-console
377
+ console.log(` ${i + 1}) ${orgRows[i].name}`);
378
+ }
379
+ const defaultChoice = currentIndex >= 0 ? currentIndex + 1 : 1;
380
+ const picked = await askChoice(
381
+ `Select org [1-${orgRows.length}] (default ${defaultChoice}): `,
382
+ 1,
383
+ orgRows.length,
384
+ defaultChoice
385
+ );
386
+ orgId = orgRows[picked - 1].id;
387
+ orgName = orgRows[picked - 1].name;
388
+ }
389
+ } else if (currentOrgId) {
390
+ const updateOrg = await askYesNo("Organization already set. Update it? [y/N]: ", false);
391
+ if (!updateOrg) {
392
+ orgId = currentOrgId;
393
+ orgName = "saved organization";
394
+ } else {
395
+ orgId = String(await ask("New Qualty Org ID [required]: ", "")).trim();
396
+ }
397
+ } else {
398
+ orgId = String(await ask("Qualty Org ID [required]: ", "")).trim();
399
+ }
400
+
401
+ if (!orgId) {
402
+ throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
403
+ }
404
+
405
+ const next = {
406
+ org_id: orgId,
407
+ };
408
+ // Keep token in the same place MCP uses.
409
+ writeSharedCredentials(token);
410
+ writeQualtyConfig(next);
411
+ // Keep org in MCP-compatible per-project config too.
412
+ writeProjectConfig({ default_org_id: orgId });
413
+ // eslint-disable-next-line no-console
414
+ console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
415
+ // eslint-disable-next-line no-console
416
+ console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
417
+ // eslint-disable-next-line no-console
418
+ console.log(`[qualty] Saved project org to ${QUALTY_PROJECT_CONFIG_PATH} (MCP-compatible)`);
419
+ // eslint-disable-next-line no-console
420
+ console.log(`[qualty] Org: ${orgName || "configured"}`);
421
+ // eslint-disable-next-line no-console
422
+ console.log(`[qualty] Token: ${maskSecret(token)}`);
423
+ } finally {
424
+ rl.close();
425
+ }
426
+ }
427
+
177
428
  function parseBoolean(value, defaultValue) {
178
429
  if (value === undefined) return defaultValue;
179
430
  if (typeof value === "boolean") return value;
@@ -519,9 +770,10 @@ function printQualtyViewLogsReport(executionId, status) {
519
770
  }
520
771
 
521
772
  async function runCi(args) {
522
- const apiUrl = resolveApiUrl(args);
523
- const token = args.token || process.env.QUALTY_API_TOKEN;
524
- const orgId = args.org || process.env.QUALTY_ORG_ID;
773
+ const config = readQualtyConfig();
774
+ const apiUrl = resolveApiUrl(args, config);
775
+ const token = resolveToken(args, config);
776
+ const orgId = resolveOrgId(args, config);
525
777
  const projectId = args.project;
526
778
  const suiteId = args["suite-id"];
527
779
  const explicitIds = String(args.ids || "")
@@ -671,9 +923,10 @@ async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, expl
671
923
  }
672
924
 
673
925
  async function runResolve(args) {
674
- const apiUrl = resolveApiUrl(args);
675
- const token = args.token || process.env.QUALTY_API_TOKEN;
676
- const orgId = args.org || process.env.QUALTY_ORG_ID;
926
+ const config = readQualtyConfig();
927
+ const apiUrl = resolveApiUrl(args, config);
928
+ const token = resolveToken(args, config);
929
+ const orgId = resolveOrgId(args, config);
677
930
  const projectId = args.project;
678
931
  const suiteId = args["suite-id"];
679
932
  const explicitIds = String(args.ids || "")
@@ -713,6 +966,127 @@ async function runResolve(args) {
713
966
  }
714
967
  }
715
968
 
969
+ async function maybePromptSuiteIdForLocalRun({ args, apiUrl, token, orgId }) {
970
+ const existingSuiteId = String(args["suite-id"] || "").trim();
971
+ const explicitIds = String(args.ids || "")
972
+ .split(",")
973
+ .map((id) => id.trim())
974
+ .filter(Boolean);
975
+ if (existingSuiteId || explicitIds.length > 0) {
976
+ return existingSuiteId || "";
977
+ }
978
+
979
+ const projectId = String(args.project || "").trim();
980
+ if (!projectId) {
981
+ return "";
982
+ }
983
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
984
+ return "";
985
+ }
986
+
987
+ const suites = await apiRequest({
988
+ apiUrl,
989
+ token,
990
+ orgId,
991
+ path: `/api/v1/test-suites?project_id=${encodeURIComponent(projectId)}`,
992
+ });
993
+ const rows = (Array.isArray(suites) ? suites : [])
994
+ .map((s) => ({
995
+ id: String(s?.id || "").trim(),
996
+ name: String(s?.name || "").trim() || "Unnamed suite",
997
+ jobCount: Number(Array.isArray(s?.job_ids) ? s.job_ids.length : 0),
998
+ sequenceCount: Number(Array.isArray(s?.sequence_ids) ? s.sequence_ids.length : 0),
999
+ }))
1000
+ .filter((s) => s.id);
1001
+
1002
+ if (rows.length === 0) {
1003
+ throw new Error(
1004
+ "No test suites found for this project. Create a suite first in dashboard, or pass --ids explicitly."
1005
+ );
1006
+ }
1007
+
1008
+ // eslint-disable-next-line no-console
1009
+ console.log("[qualty] No --suite-id or --ids provided. Choose a suite:");
1010
+ for (let i = 0; i < rows.length; i += 1) {
1011
+ const s = rows[i];
1012
+ const details = `${s.jobCount} test(s)${
1013
+ s.sequenceCount > 0 ? `, ${s.sequenceCount} sequence(s)` : ""
1014
+ }`;
1015
+ // eslint-disable-next-line no-console
1016
+ console.log(` ${i + 1}) ${s.name} (${details})`);
1017
+ }
1018
+
1019
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1020
+ try {
1021
+ while (true) {
1022
+ const raw = String(
1023
+ await rl.question(`Select suite [1-${rows.length}] (default 1): `)
1024
+ ).trim();
1025
+ const picked = raw ? Number(raw) : 1;
1026
+ if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
1027
+ const suite = rows[picked - 1];
1028
+ // eslint-disable-next-line no-console
1029
+ console.log(`[qualty] Selected suite: ${suite.name}`);
1030
+ return suite.id;
1031
+ }
1032
+ // eslint-disable-next-line no-console
1033
+ console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
1034
+ }
1035
+ } finally {
1036
+ rl.close();
1037
+ }
1038
+ }
1039
+
1040
+ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1041
+ const existingProjectId = String(args.project || "").trim();
1042
+ if (existingProjectId) return existingProjectId;
1043
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return existingProjectId;
1044
+
1045
+ const projectsPayload = await apiRequest({
1046
+ apiUrl,
1047
+ token,
1048
+ orgId,
1049
+ path: "/api/v1/projects",
1050
+ });
1051
+ const rows = (Array.isArray(projectsPayload) ? projectsPayload : [])
1052
+ .map((p) => ({
1053
+ id: String(p?.id || "").trim(),
1054
+ name: String(p?.name || "").trim() || "Unnamed project",
1055
+ }))
1056
+ .filter((p) => p.id);
1057
+
1058
+ if (rows.length === 0) {
1059
+ throw new Error("No projects found for this account/org. Create a project first.");
1060
+ }
1061
+
1062
+ // eslint-disable-next-line no-console
1063
+ console.log("[qualty] No --project provided. Choose a project:");
1064
+ for (let i = 0; i < rows.length; i += 1) {
1065
+ // eslint-disable-next-line no-console
1066
+ console.log(` ${i + 1}) ${rows[i].name}`);
1067
+ }
1068
+
1069
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1070
+ try {
1071
+ while (true) {
1072
+ const raw = String(
1073
+ await rl.question(`Select project [1-${rows.length}] (default 1): `)
1074
+ ).trim();
1075
+ const picked = raw ? Number(raw) : 1;
1076
+ if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
1077
+ const project = rows[picked - 1];
1078
+ // eslint-disable-next-line no-console
1079
+ console.log(`[qualty] Selected project: ${project.name}`);
1080
+ return project.id;
1081
+ }
1082
+ // eslint-disable-next-line no-console
1083
+ console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
1084
+ }
1085
+ } finally {
1086
+ rl.close();
1087
+ }
1088
+ }
1089
+
716
1090
  async function main() {
717
1091
  const args = parseArgs(process.argv);
718
1092
  const command = args._[0];
@@ -724,14 +1098,36 @@ async function main() {
724
1098
  await runConnect(args);
725
1099
  return;
726
1100
  }
1101
+ if (command === "setup") {
1102
+ await runSetup(args);
1103
+ return;
1104
+ }
727
1105
  if (command === "run") {
1106
+ const config = readQualtyConfig();
728
1107
  if (parseBoolean(args.local, false)) {
1108
+ const apiUrl = resolveApiUrl(args, config);
1109
+ const token = resolveToken(args, config);
1110
+ const orgId = resolveOrgId(args, config);
1111
+ const projectId =
1112
+ (await maybePromptProjectIdForLocalRun({
1113
+ args,
1114
+ apiUrl,
1115
+ token,
1116
+ orgId,
1117
+ })) || args.project;
1118
+ const suiteId =
1119
+ (await maybePromptSuiteIdForLocalRun({
1120
+ args: { ...args, project: projectId },
1121
+ apiUrl,
1122
+ token,
1123
+ orgId,
1124
+ })) || args["suite-id"];
729
1125
  await runLocalCi({
730
- apiUrl: resolveApiUrl(args),
731
- token: args.token || process.env.QUALTY_API_TOKEN,
732
- orgId: args.org || process.env.QUALTY_ORG_ID,
733
- projectId: args.project,
734
- suiteId: args["suite-id"],
1126
+ apiUrl,
1127
+ token,
1128
+ orgId,
1129
+ projectId,
1130
+ suiteId,
735
1131
  explicitIds: String(args.ids || "")
736
1132
  .split(",")
737
1133
  .map((id) => id.trim())
@@ -752,7 +1148,7 @@ async function main() {
752
1148
  await runResolve(args);
753
1149
  return;
754
1150
  }
755
- if (command !== "connect" && command !== "run" && command !== "resolve") {
1151
+ if (command !== "setup" && command !== "connect" && command !== "run" && command !== "resolve") {
756
1152
  throw new Error(`Unknown command: ${command}`);
757
1153
  }
758
1154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"
@@ -11,6 +11,7 @@
11
11
  "bin"
12
12
  ],
13
13
  "dependencies": {
14
- "playwright": "1.60.0"
14
+ "playwright": "1.60.0",
15
+ "rrweb": "^2.0.0-alpha.4"
15
16
  }
16
17
  }