blastproof 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +140 -33
- package/dist/cli.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -210,7 +210,7 @@ Common flags — `blastproof <command> --help` has the full list:
|
|
|
210
210
|
| `--write` | `plan` only — persist drafts instead of previewing |
|
|
211
211
|
| `--max-llm-calls` · `--max-tokens` · `--max-duration` | [Bound what a run may spend](./docs/configuration.md#budget--bounding-what-a-run-spends) |
|
|
212
212
|
|
|
213
|
-
Exit codes: **0** pass, **1** the gate failed, **2** usage or config error.
|
|
213
|
+
Exit codes: **0** pass, **1** the gate failed or the run stopped (its budget, or the model provider refusing a call: `Run incomplete:`), **2** usage or config error.
|
|
214
214
|
|
|
215
215
|
**Generated drafts are never executed and never affect the score.** An unreviewed model-written test in the merge path fails in two directions: a hallucinated expectation blocks a correct PR, and a credulous one waves a broken change through while looking like coverage. `plan` makes the gap visible with a draft to review; it does not make an uncovered route safe.
|
|
216
216
|
|
package/dist/cli.js
CHANGED
|
@@ -219,7 +219,9 @@ function describeLimit(limit, observed, configured) {
|
|
|
219
219
|
return `deadline exceeded: reached the configured maximum of ${(configured / 1e3).toFixed(0)}s (elapsed ${(observed / 1e3).toFixed(1)}s)`;
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
-
var
|
|
222
|
+
var RunStoppedError = class extends Error {
|
|
223
|
+
};
|
|
224
|
+
var BudgetExhaustedError = class extends RunStoppedError {
|
|
223
225
|
limit;
|
|
224
226
|
observed;
|
|
225
227
|
configured;
|
|
@@ -231,6 +233,30 @@ var BudgetExhaustedError = class extends Error {
|
|
|
231
233
|
this.configured = configured;
|
|
232
234
|
}
|
|
233
235
|
};
|
|
236
|
+
function refusalRemedy(statusCode) {
|
|
237
|
+
if (statusCode === void 0) {
|
|
238
|
+
return "The provider could not be reached after 3 attempts: check llm.base_url and the network, then run again.";
|
|
239
|
+
}
|
|
240
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
241
|
+
return "Check the API key in the variable named by llm.api_key_env, then run again.";
|
|
242
|
+
}
|
|
243
|
+
if (statusCode === 402) return "Add credit to the provider account, then run again.";
|
|
244
|
+
if (statusCode === 408 || statusCode === 409 || statusCode === 429 || statusCode >= 500) {
|
|
245
|
+
return "The provider was unavailable after 3 attempts: run again later.";
|
|
246
|
+
}
|
|
247
|
+
return void 0;
|
|
248
|
+
}
|
|
249
|
+
var ProviderRefusedError = class extends RunStoppedError {
|
|
250
|
+
statusCode;
|
|
251
|
+
constructor(statusCode, detail) {
|
|
252
|
+
const status = statusCode === void 0 ? "no response" : `HTTP ${statusCode}`;
|
|
253
|
+
const remedy = refusalRemedy(statusCode);
|
|
254
|
+
const separator = /[.!?]$/.test(detail.trim()) ? " " : ". ";
|
|
255
|
+
super(`model provider refused the request (${status}): ${detail}${remedy ? `${separator}${remedy}` : ""}`);
|
|
256
|
+
this.name = "ProviderRefusedError";
|
|
257
|
+
this.statusCode = statusCode;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
234
260
|
var RunBudget = class {
|
|
235
261
|
maxCalls;
|
|
236
262
|
maxTokens;
|
|
@@ -333,6 +359,9 @@ function referencedEnvVars(text) {
|
|
|
333
359
|
}
|
|
334
360
|
return [...names];
|
|
335
361
|
}
|
|
362
|
+
function placeholdersAsLabels(text) {
|
|
363
|
+
return text.replace(ENV_PLACEHOLDER, (_match, name) => redactionLabel(name));
|
|
364
|
+
}
|
|
336
365
|
function escapeRegExp(value) {
|
|
337
366
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
338
367
|
}
|
|
@@ -343,6 +372,12 @@ var REDACTION_PREFIX = "[redacted";
|
|
|
343
372
|
function redactionLabel(name) {
|
|
344
373
|
return name === void 0 ? `${REDACTION_PREFIX}]` : `${REDACTION_PREFIX} ${name}]`;
|
|
345
374
|
}
|
|
375
|
+
var LABEL = /\[redacted ([A-Za-z_][A-Za-z0-9_]*)\]/g;
|
|
376
|
+
function labelledVariables(text) {
|
|
377
|
+
const names = /* @__PURE__ */ new Set();
|
|
378
|
+
for (const match of text.matchAll(LABEL)) if (match[1]) names.add(match[1]);
|
|
379
|
+
return [...names];
|
|
380
|
+
}
|
|
346
381
|
function compile(secrets, names) {
|
|
347
382
|
return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value), label: redactionLabel(names?.get(value)) }));
|
|
348
383
|
}
|
|
@@ -591,11 +626,13 @@ function describeAction(action) {
|
|
|
591
626
|
return `${action.action}${target}${value}`;
|
|
592
627
|
}
|
|
593
628
|
function identity(action) {
|
|
629
|
+
const role = action.target?.role ?? "";
|
|
630
|
+
const name = action.target?.name ?? "";
|
|
594
631
|
return JSON.stringify([
|
|
595
632
|
action.action,
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
action.target?.text ?? "",
|
|
633
|
+
role,
|
|
634
|
+
normalise(name),
|
|
635
|
+
role || name ? "" : action.target?.text ?? "",
|
|
599
636
|
action.value ?? ""
|
|
600
637
|
]);
|
|
601
638
|
}
|
|
@@ -845,7 +882,7 @@ async function executeTest(page, test, options) {
|
|
|
845
882
|
iterationsLeft: maxIterationsPerStep - iterations
|
|
846
883
|
});
|
|
847
884
|
} catch (error) {
|
|
848
|
-
if (error instanceof
|
|
885
|
+
if (error instanceof RunStoppedError) throw error;
|
|
849
886
|
failedAttempts++;
|
|
850
887
|
lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
|
|
851
888
|
if (failedAttempts >= maxRetries) {
|
|
@@ -873,23 +910,35 @@ async function executeTest(page, test, options) {
|
|
|
873
910
|
}
|
|
874
911
|
if (action.action === "assert") {
|
|
875
912
|
const expectation = action.expectation ?? action.reasoning;
|
|
876
|
-
let judgment
|
|
877
|
-
|
|
878
|
-
mask(expectation),
|
|
879
|
-
maskedSnap,
|
|
880
|
-
recovery.stepHistory()
|
|
881
|
-
);
|
|
882
|
-
if (!judgment.pass) {
|
|
883
|
-
await waitForSettled(page);
|
|
884
|
-
const freshSnap = await takeSnapshot(page);
|
|
885
|
-
const maskedFresh = mask(freshSnap);
|
|
886
|
-
recovery.observe(maskedFresh);
|
|
913
|
+
let judgment;
|
|
914
|
+
try {
|
|
887
915
|
judgment = await brain.judge(
|
|
888
916
|
mask(step),
|
|
889
917
|
mask(expectation),
|
|
890
|
-
|
|
918
|
+
maskedSnap,
|
|
891
919
|
recovery.stepHistory()
|
|
892
920
|
);
|
|
921
|
+
if (!judgment.pass) {
|
|
922
|
+
await waitForSettled(page);
|
|
923
|
+
const freshSnap = await takeSnapshot(page);
|
|
924
|
+
const maskedFresh = mask(freshSnap);
|
|
925
|
+
recovery.observe(maskedFresh);
|
|
926
|
+
judgment = await brain.judge(
|
|
927
|
+
mask(step),
|
|
928
|
+
mask(expectation),
|
|
929
|
+
maskedFresh,
|
|
930
|
+
recovery.stepHistory()
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
} catch (error) {
|
|
934
|
+
if (error instanceof RunStoppedError) throw error;
|
|
935
|
+
failedAttempts++;
|
|
936
|
+
lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
|
|
937
|
+
emitAction(index, action, lastResult);
|
|
938
|
+
if (failedAttempts >= maxRetries) {
|
|
939
|
+
throw new StepFailure(lastResult);
|
|
940
|
+
}
|
|
941
|
+
continue;
|
|
893
942
|
}
|
|
894
943
|
const result = judgment.pass ? `ok: assertion passed: ${judgment.reason}` : `assertion failed: ${judgment.reason}`;
|
|
895
944
|
emitAction(index, action, result);
|
|
@@ -934,7 +983,7 @@ async function executeTest(page, test, options) {
|
|
|
934
983
|
}
|
|
935
984
|
}
|
|
936
985
|
} catch (error) {
|
|
937
|
-
if (error instanceof
|
|
986
|
+
if (error instanceof RunStoppedError) throw error;
|
|
938
987
|
stepFailedReason = mask(error instanceof Error ? error.message : String(error));
|
|
939
988
|
}
|
|
940
989
|
const status = stepFailedReason ? "failed" : "passed";
|
|
@@ -1035,12 +1084,24 @@ async function runJourney(...args) {
|
|
|
1035
1084
|
try {
|
|
1036
1085
|
return await executeTest(...args);
|
|
1037
1086
|
} catch (error) {
|
|
1038
|
-
if (error instanceof
|
|
1087
|
+
if (error instanceof RunStoppedError) throw error;
|
|
1039
1088
|
throw new AuthError(
|
|
1040
1089
|
`Authentication could not run: ${error instanceof Error ? error.message : String(error)}`
|
|
1041
1090
|
);
|
|
1042
1091
|
}
|
|
1043
1092
|
}
|
|
1093
|
+
async function verifyJudgment(brain, verify, snapshot, maskText, maxRetries = 3) {
|
|
1094
|
+
let lastError = "";
|
|
1095
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1096
|
+
try {
|
|
1097
|
+
return await brain.judge(verify, verify, await snapshot());
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
if (error instanceof RunStoppedError) throw error;
|
|
1100
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
throw new AuthError(`Authentication could not be verified: ${maskText(lastError)}`);
|
|
1104
|
+
}
|
|
1044
1105
|
async function fromSteps(options) {
|
|
1045
1106
|
const { auth, baseUrl, browser, brain, maxRetries, snapshot, timeoutMs, maxSnapshotLines, onEvent, mask } = options;
|
|
1046
1107
|
const steps = auth.steps;
|
|
@@ -1078,7 +1139,13 @@ async function fromSteps(options) {
|
|
|
1078
1139
|
);
|
|
1079
1140
|
}
|
|
1080
1141
|
if (auth.verify) {
|
|
1081
|
-
const judgment = await
|
|
1142
|
+
const judgment = await verifyJudgment(
|
|
1143
|
+
brain,
|
|
1144
|
+
auth.verify,
|
|
1145
|
+
async () => mask.mask(await takeSnapshot(page)),
|
|
1146
|
+
(text) => mask.mask(text),
|
|
1147
|
+
maxRetries
|
|
1148
|
+
);
|
|
1082
1149
|
if (!judgment.pass) {
|
|
1083
1150
|
throw new AuthError(`Authentication could not be verified: ${mask.mask(judgment.reason)}`);
|
|
1084
1151
|
}
|
|
@@ -1450,7 +1517,7 @@ warning: ${findings.length} file(s) changed in the working tree are not in the d
|
|
|
1450
1517
|
}
|
|
1451
1518
|
|
|
1452
1519
|
// src/llm/brain.ts
|
|
1453
|
-
import { generateObject } from "ai";
|
|
1520
|
+
import { APICallError, generateObject, RetryError } from "ai";
|
|
1454
1521
|
|
|
1455
1522
|
// src/llm/prompts.ts
|
|
1456
1523
|
function agentSystemPrompt() {
|
|
@@ -1502,6 +1569,8 @@ A value sitting in a control that was just typed into \u2014 an open dialog's te
|
|
|
1502
1569
|
|
|
1503
1570
|
A step that names an ACTION (submit, click, create, add, ...) is satisfied by evidence the action took effect, not by the action's own control still being on the page. A successful action ordinarily replaces or moves past exactly the form, button or field the step names, so that control's absence is normal evidence of success, not evidence the step is unverifiable \u2014 do not fail such a step only because you can no longer see the thing it names. Fail it instead when the snapshot shows the action did NOT take effect: an error message, a validation warning, or the very same pre-action page still in front of you with nothing changed. A different page, a new state, or the result the action was meant to produce counts as evidence it worked.
|
|
1504
1571
|
|
|
1572
|
+
An outcome may already hold before this step acts: an earlier step, or the application itself, got there first. A step asking for something to be gone, closed or dismissed is satisfied by that thing being absent from the snapshot. It does not also require the thing to have been there, or this step to have removed it, and a different element that is present (another dialog, another banner) is not the one the step names. Whether the step's action ran is not what you decide: judge the state the step asks for.
|
|
1573
|
+
|
|
1505
1574
|
You may also be shown the actions already performed in this step, with their results. That record tells you what was ATTEMPTED and what it produced \u2014 for instance that a navigation was performed and which URL the server ultimately served, or that a form was submitted. Use it to avoid concluding that something never happened when the page simply cannot show it any more: a navigation the server redirected does not leave the browser at the path that was requested, and that is what success looks like, not failure.
|
|
1506
1575
|
|
|
1507
1576
|
The record is not evidence that the step's outcome holds. An action reported as \`ok\` establishes that it ran and what it returned; whether the thing the step describes is now TRUE is still decided by the snapshot alone. Never pass a step because the record shows an action succeeded while the snapshot does not show the outcome.
|
|
@@ -1607,8 +1676,13 @@ function parseAgentAction(value) {
|
|
|
1607
1676
|
return parsedAgentActionSchema.safeParse(value);
|
|
1608
1677
|
}
|
|
1609
1678
|
var assertJudgmentSchema = z2.object({
|
|
1610
|
-
|
|
1611
|
-
|
|
1679
|
+
outcome: z2.string().describe(
|
|
1680
|
+
'The state the STEP asks for, rewritten as a sentence about the page with its action removed: "click Save and verify the note is listed" becomes "the note is listed"; "open the Account menu and verify it shows the email" becomes "the Account menu shows the email".'
|
|
1681
|
+
),
|
|
1682
|
+
reason: z2.string().describe("One sentence: whether the snapshot shows that outcome."),
|
|
1683
|
+
pass: z2.boolean().describe(
|
|
1684
|
+
"Whether the snapshot shows that outcome. The expectation is only a claim offered in support; it never replaces the step. False if any part of the outcome the step asks for is not shown, or cannot be assessed from this snapshot. An action the step names (click, dismiss, submit) is how its outcome is reached, not part of it: an outcome that holds passes whether or not that action was needed. An outcome that is an absence (gone, closed, dismissed, removed) is shown by the thing being absent."
|
|
1685
|
+
)
|
|
1612
1686
|
});
|
|
1613
1687
|
var generatedTestSchema = z2.object({
|
|
1614
1688
|
summary: z2.string().min(1).describe('One line naming the user journey this test covers, e.g. "Applying a discount updates the cart total".'),
|
|
@@ -1636,13 +1710,20 @@ function withProviderDetail(error) {
|
|
|
1636
1710
|
error.message = `${error.message} \u2014 provider said: ${detail}`;
|
|
1637
1711
|
return error;
|
|
1638
1712
|
}
|
|
1713
|
+
function asProviderRefusal(error) {
|
|
1714
|
+
const cause = RetryError.isInstance(error) ? error.lastError : error;
|
|
1715
|
+
if (!APICallError.isInstance(cause)) return error;
|
|
1716
|
+
if (cause.statusCode !== void 0 && cause.statusCode < 400) return error;
|
|
1717
|
+
withProviderDetail(cause);
|
|
1718
|
+
return new ProviderRefusedError(cause.statusCode, cause.message);
|
|
1719
|
+
}
|
|
1639
1720
|
async function countedGenerate(generate, budget, options) {
|
|
1640
1721
|
budget.check();
|
|
1641
1722
|
let result;
|
|
1642
1723
|
try {
|
|
1643
1724
|
result = await generate(options);
|
|
1644
1725
|
} catch (error) {
|
|
1645
|
-
throw withProviderDetail(error);
|
|
1726
|
+
throw asProviderRefusal(withProviderDetail(error));
|
|
1646
1727
|
}
|
|
1647
1728
|
budget.record(result.usage);
|
|
1648
1729
|
return result;
|
|
@@ -1665,11 +1746,20 @@ function createBrain(model, generate = generateObject, budget) {
|
|
|
1665
1746
|
return parsed.data;
|
|
1666
1747
|
},
|
|
1667
1748
|
async judge(step, expectation, snapshot, stepHistory) {
|
|
1749
|
+
const asJudged = stepHistory?.map((entry) => ({
|
|
1750
|
+
action: placeholdersAsLabels(entry.action),
|
|
1751
|
+
result: placeholdersAsLabels(entry.result)
|
|
1752
|
+
}));
|
|
1668
1753
|
const result = await countedGenerate(generate, budget, {
|
|
1669
1754
|
model,
|
|
1670
1755
|
schema: assertJudgmentSchema,
|
|
1671
1756
|
system: assertSystemPrompt(),
|
|
1672
|
-
prompt: assertUserPrompt(
|
|
1757
|
+
prompt: assertUserPrompt(
|
|
1758
|
+
placeholdersAsLabels(step),
|
|
1759
|
+
placeholdersAsLabels(expectation),
|
|
1760
|
+
snapshot,
|
|
1761
|
+
asJudged
|
|
1762
|
+
),
|
|
1673
1763
|
temperature: 0
|
|
1674
1764
|
});
|
|
1675
1765
|
const parsed = assertJudgmentSchema.safeParse(result.object);
|
|
@@ -1678,10 +1768,27 @@ function createBrain(model, generate = generateObject, budget) {
|
|
|
1678
1768
|
`Model returned an invalid assert judgment: ${parsed.error.issues[0]?.message ?? "unknown"}`
|
|
1679
1769
|
);
|
|
1680
1770
|
}
|
|
1681
|
-
return parsed.data;
|
|
1771
|
+
return secretMismatch(placeholdersAsLabels(step), snapshot, asJudged, parsed.data);
|
|
1682
1772
|
}
|
|
1683
1773
|
};
|
|
1684
1774
|
}
|
|
1775
|
+
function secretMismatch(judgedStep, snapshot, judgedRecord, judgment) {
|
|
1776
|
+
if (!judgment.pass) return judgment;
|
|
1777
|
+
const onPage = labelledVariables(snapshot);
|
|
1778
|
+
const inRecord = labelledVariables((judgedRecord ?? []).map((e) => `${e.action}
|
|
1779
|
+
${e.result}`).join("\n"));
|
|
1780
|
+
for (const name of labelledVariables(judgedStep)) {
|
|
1781
|
+
const others = onPage.filter((other) => other !== name);
|
|
1782
|
+
if (onPage.includes(name) || inRecord.includes(name) || others.length === 0) continue;
|
|
1783
|
+
const shown = others.map((other) => redactionLabel(other)).join(", ");
|
|
1784
|
+
return {
|
|
1785
|
+
...judgment,
|
|
1786
|
+
pass: false,
|
|
1787
|
+
reason: `The step names ${redactionLabel(name)}, which appears neither on the page nor in this step's actions, while the page shows ${shown}: a different secret cannot satisfy it. (The judge had said: ${judgment.reason})`
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
return judgment;
|
|
1791
|
+
}
|
|
1685
1792
|
function createPlanner(model, generate = generateObject, budget) {
|
|
1686
1793
|
return {
|
|
1687
1794
|
async planTest(input) {
|
|
@@ -2541,7 +2648,7 @@ X ${r.summary} (${r.file})`);
|
|
|
2541
2648
|
}
|
|
2542
2649
|
if (notRun.length > 0) {
|
|
2543
2650
|
console.log(`
|
|
2544
|
-
${notRun.length} test(s) not run (run stopped
|
|
2651
|
+
${notRun.length} test(s) not run (run stopped: ${notRun[0].reason}):`);
|
|
2545
2652
|
for (const r of notRun) console.log(` - ${r.summary} (${r.file})`);
|
|
2546
2653
|
}
|
|
2547
2654
|
}
|
|
@@ -2866,7 +2973,7 @@ ${results.length} test file(s) could not be parsed:`);
|
|
|
2866
2973
|
onEvent: printEvent
|
|
2867
2974
|
});
|
|
2868
2975
|
} catch (error) {
|
|
2869
|
-
if (error instanceof
|
|
2976
|
+
if (error instanceof RunStoppedError) {
|
|
2870
2977
|
incomplete = error;
|
|
2871
2978
|
} else if (error instanceof AuthError) {
|
|
2872
2979
|
console.error(`error: ${error.message}`);
|
|
@@ -2896,7 +3003,7 @@ ${results.length} test file(s) could not be parsed:`);
|
|
|
2896
3003
|
if (!streaming) for (const line of lines) console.log(line);
|
|
2897
3004
|
return { index, result };
|
|
2898
3005
|
} catch (error) {
|
|
2899
|
-
if (error instanceof
|
|
3006
|
+
if (error instanceof RunStoppedError) {
|
|
2900
3007
|
stoppedBy ??= error;
|
|
2901
3008
|
if (!streaming) for (const line of lines) console.log(line);
|
|
2902
3009
|
return { index, stopped: error };
|
|
@@ -3080,7 +3187,7 @@ async function planCommand(options) {
|
|
|
3080
3187
|
onEvent: printAuthEvent
|
|
3081
3188
|
});
|
|
3082
3189
|
} catch (error) {
|
|
3083
|
-
if (error instanceof
|
|
3190
|
+
if (error instanceof RunStoppedError) {
|
|
3084
3191
|
incomplete = error;
|
|
3085
3192
|
} else if (error instanceof AuthError) {
|
|
3086
3193
|
console.error(`error: ${error.message}`);
|
|
@@ -3108,7 +3215,7 @@ async function planCommand(options) {
|
|
|
3108
3215
|
timeoutMs: config.browser.timeout_ms
|
|
3109
3216
|
});
|
|
3110
3217
|
} catch (error) {
|
|
3111
|
-
if (error instanceof
|
|
3218
|
+
if (error instanceof RunStoppedError) {
|
|
3112
3219
|
incomplete = error;
|
|
3113
3220
|
break;
|
|
3114
3221
|
}
|
|
@@ -3162,7 +3269,7 @@ ${renderTestYaml(draft, { route, base })}`);
|
|
|
3162
3269
|
if (incomplete) {
|
|
3163
3270
|
console.log(`Stopped: ${incomplete.message}`);
|
|
3164
3271
|
if (notAttempted.length > 0) {
|
|
3165
|
-
console.log("Not attempted (run
|
|
3272
|
+
console.log("Not attempted (run stopped):");
|
|
3166
3273
|
for (const route of notAttempted) console.log(` ${route}`);
|
|
3167
3274
|
}
|
|
3168
3275
|
}
|
|
@@ -3256,7 +3363,7 @@ function parsePositiveNumber(flag) {
|
|
|
3256
3363
|
};
|
|
3257
3364
|
}
|
|
3258
3365
|
var program = new Command();
|
|
3259
|
-
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.
|
|
3366
|
+
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.23.0");
|
|
3260
3367
|
program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
|
|
3261
3368
|
try {
|
|
3262
3369
|
const result = await initProject(process.cwd());
|