@koda-sl/baker-cli 0.286.0-dev.93be96120 → 0.287.0-dev.93be96120
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 +11 -27
- package/dist/cli.js +212 -189
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -38796,53 +38796,32 @@ async function foldExperiment(root, target) {
|
|
|
38796
38796
|
};
|
|
38797
38797
|
}
|
|
38798
38798
|
|
|
38799
|
-
// src/commands/experiment/goal.ts
|
|
38800
|
-
function parseEventGoal(argument) {
|
|
38801
|
-
const [name, filter] = argument.split("/");
|
|
38802
|
-
if (!name) return { error: "Name the event: --goal event:request_demo" };
|
|
38803
|
-
if (!filter) return { kind: "custom_event", name };
|
|
38804
|
-
const separator = filter.indexOf("=");
|
|
38805
|
-
if (separator === -1) {
|
|
38806
|
-
return { error: `Write the property as key=value: --goal event:${name}/section=pricing` };
|
|
38807
|
-
}
|
|
38808
|
-
return { kind: "custom_event", name, property: filter.slice(0, separator), value: filter.slice(separator + 1) };
|
|
38809
|
-
}
|
|
38810
|
-
function parseGoal(raw) {
|
|
38811
|
-
if (raw === void 0 || raw === "") return void 0;
|
|
38812
|
-
const separator = raw.indexOf(":");
|
|
38813
|
-
const kind = separator === -1 ? raw : raw.slice(0, separator);
|
|
38814
|
-
const argument = separator === -1 ? "" : raw.slice(separator + 1);
|
|
38815
|
-
if (kind === "leads") return argument ? { kind: "conversion", flowSlug: argument } : { kind: "conversion" };
|
|
38816
|
-
if (kind === "click") return argument ? { kind: "outbound_click", targetHost: argument } : { kind: "outbound_click" };
|
|
38817
|
-
if (kind === "event") return parseEventGoal(argument);
|
|
38818
|
-
return {
|
|
38819
|
-
error: `Unknown goal "${raw}". Use leads, leads:<form>, event:<name>, event:<name>/<key>=<value>, click, or click:<host>`
|
|
38820
|
-
};
|
|
38821
|
-
}
|
|
38822
|
-
|
|
38823
38799
|
// src/commands/experiment/hints.ts
|
|
38824
|
-
var
|
|
38800
|
+
var LIVE_HINT = {
|
|
38825
38801
|
keep_running: (row) => `${row.experimentId} cannot yet tell the two pages apart \u2014 do not read its numbers as a result, and do not end it hoping for one. ${describeWait(row)}`,
|
|
38826
|
-
winner: (row) => `${row.experimentId} has a winner. Run \`baker experiment finish --id ${row.experimentId}\` to
|
|
38827
|
-
no_difference: (row) => `${row.experimentId} measured the two pages as alike \u2014 it collected enough traffic to see the change it was looking for, and there was none.
|
|
38828
|
-
stopped_early_harmful: (row) => `${row.experimentId} is doing damage
|
|
38829
|
-
// Unreachable
|
|
38830
|
-
//
|
|
38831
|
-
//
|
|
38832
|
-
|
|
38833
|
-
invalid: (row) => `${row.experimentId} cannot be read: ${row.invalidReason === "crossover" ? "too many visitors saw both versions" : "the traffic did not split evenly"}.
|
|
38802
|
+
winner: (row) => row.winner === "variant" ? `${row.experimentId} has a winner: the new variant. Run \`baker experiment finish --id ${row.experimentId} --keep variant\` to show it to everyone, then \`baker experiment fold\`.` : `${row.experimentId} has a winner: the page as it is. Run \`baker experiment finish --id ${row.experimentId} --keep original\` to put all the traffic back on it.`,
|
|
38803
|
+
no_difference: (row) => `${row.experimentId} measured the two pages as alike \u2014 it collected enough traffic to see the change it was looking for, and there was none. End it with \`--keep original\` and test a bigger change \u2014 a headline, an offer, the shape of the page, not a button colour.`,
|
|
38804
|
+
stopped_early_harmful: (row) => `${row.experimentId} is doing damage and is STILL being shown to half the visitors. Run \`baker experiment finish --id ${row.experimentId} --keep original\` now.`,
|
|
38805
|
+
// Unreachable on a live test — only ending one produces it. The Record is
|
|
38806
|
+
// exhaustive by type so a verdict added upstream fails to compile here rather
|
|
38807
|
+
// than printing nothing.
|
|
38808
|
+
inconclusive: (row) => `${row.experimentId} was ended before it concluded and found nothing.`,
|
|
38809
|
+
invalid: (row) => `${row.experimentId} cannot be read: ${row.invalidReason === "crossover" ? "too many visitors saw both versions" : "the traffic did not split evenly"}. End it with \`--keep original\` and start again rather than reporting anything from it.`
|
|
38834
38810
|
};
|
|
38835
38811
|
function describeWait(row) {
|
|
38836
38812
|
if (row.willNotConclude) {
|
|
38837
|
-
return "This page's traffic will not settle it at any point, so
|
|
38813
|
+
return "This page's traffic will not settle it at any point, so end it with `--keep original` and test a bigger change.";
|
|
38838
38814
|
}
|
|
38839
38815
|
if (row.earliestDecisionAt === null) {
|
|
38840
38816
|
return "This page has produced no visitors yet, so there is nothing to project a finish date from.";
|
|
38841
38817
|
}
|
|
38842
38818
|
return `Earliest it could say anything: ${new Date(row.earliestDecisionAt).toISOString().slice(0, 10)}.`;
|
|
38843
38819
|
}
|
|
38820
|
+
function pausedNote(row) {
|
|
38821
|
+
return row.status === "paused" ? `${row.experimentId} is paused \u2014 everyone sees the original. \`baker experiment resume --id ${row.experimentId}\` restarts the split with the same sides. ` : "";
|
|
38822
|
+
}
|
|
38844
38823
|
function buildStatusHints(experiments) {
|
|
38845
|
-
const hints2 = experiments.filter((row) => row.status
|
|
38824
|
+
const hints2 = experiments.filter((row) => row.status !== "finished").map((row) => pausedNote(row) + LIVE_HINT[row.verdict](row));
|
|
38846
38825
|
if (experiments.length === 0) {
|
|
38847
38826
|
hints2.push(
|
|
38848
38827
|
"No tests yet. `baker experiment plan --landing <slug> --variant <slug>` says whether a page has enough traffic for one before you build the alternative."
|
|
@@ -38853,40 +38832,48 @@ function buildStatusHints(experiments) {
|
|
|
38853
38832
|
function asLift(value) {
|
|
38854
38833
|
return `+${Math.round(value * 100)}%`;
|
|
38855
38834
|
}
|
|
38856
|
-
function
|
|
38857
|
-
|
|
38858
|
-
if (
|
|
38859
|
-
return
|
|
38835
|
+
function horizonsHint(plan) {
|
|
38836
|
+
const settled = plan.horizons.filter((horizon) => horizon.detectableLift !== null);
|
|
38837
|
+
if (settled.length === 0) {
|
|
38838
|
+
return "At this page's traffic no window up to 90 days would gather enough visitors to settle anything, at any size of change. The constraint is the traffic, not the test.";
|
|
38860
38839
|
}
|
|
38861
|
-
|
|
38840
|
+
const parts = settled.map((horizon) => `${horizon.days} days \u2192 ${asLift(horizon.detectableLift ?? 0)}`);
|
|
38841
|
+
return `The smallest change each window could settle on this page's traffic: ${parts.join(", ")}. Design the alternative to be at least that different.`;
|
|
38862
38842
|
}
|
|
38863
|
-
function
|
|
38864
|
-
|
|
38843
|
+
function conversionsHint(plan) {
|
|
38844
|
+
if (plan.conversions.length <= 1) return null;
|
|
38845
|
+
const others = plan.conversions.filter((entry) => entry.name !== plan.goal);
|
|
38846
|
+
return `This page's visitors also went on to: ${others.map((entry) => `\u201C${entry.name}\u201D (${entry.convertingVisitors})`).join(", ")}. They are reported beside the goal on every result, and decide nothing.`;
|
|
38847
|
+
}
|
|
38848
|
+
function buildPlanHints(plan) {
|
|
38849
|
+
const horizons = horizonsHint(plan);
|
|
38865
38850
|
if (!plan.canRun) {
|
|
38866
38851
|
return [
|
|
38867
38852
|
plan.reason ?? "This test cannot run on this page.",
|
|
38868
|
-
...
|
|
38853
|
+
...horizons && plan.visitors > 0 ? [horizons] : [],
|
|
38869
38854
|
"A test that cannot conclude is worse than no test \u2014 it still produces a number, and somebody acts on it."
|
|
38870
38855
|
];
|
|
38871
38856
|
}
|
|
38872
38857
|
const hints2 = [
|
|
38873
|
-
`This test
|
|
38858
|
+
`This test is read on \u201C${plan.goal}\u201D. That is fixed when it starts and cannot be changed later.`,
|
|
38874
38859
|
// Fires at the exact moment the hypothesis gets invented, whether or not
|
|
38875
38860
|
// the agent read the family doc. `plan` says a question CAN be settled here
|
|
38876
38861
|
// and says nothing about which question is worth asking — and a well-run
|
|
38877
38862
|
// test of a bad idea costs three weeks and returns `no_difference`.
|
|
38878
|
-
"Now decide WHAT to change, from evidence rather than taste. These numbers say which page and which step; they cannot say why. A second opinion answers why \u2014 Microsoft Clarity for what visitors actually do on the page, `baker gsc` for what they searched, `baker ads <platform>` for what the ad promised, `baker ga4` for the client's own measurement. `baker capabilities` lists everything this client has in one call
|
|
38863
|
+
"Now decide WHAT to change, from evidence rather than taste. These numbers say which page and which step; they cannot say why. A second opinion answers why \u2014 Microsoft Clarity for what visitors actually do on the page, `baker gsc` for what they searched, `baker ads <platform>` for what the ad promised, `baker ga4` for the client's own measurement. `baker capabilities` lists everything this client has in one call. Message match \u2014 what the ad or the search promised against what the page opens with \u2014 is the cheapest hypothesis worth testing and is wrong surprisingly often.",
|
|
38879
38864
|
// The base always works, so a missing integration is never a reason to
|
|
38880
38865
|
// stop. Naming what it would have shown is worth more to the client than
|
|
38881
38866
|
// either a silent gap or a run that waited.
|
|
38882
38867
|
"If none of that is connected, this page's own drop-off is enough to test on \u2014 say which source you lacked and what it would have told you, then run the test anyway. A test never waits on an integration."
|
|
38883
38868
|
];
|
|
38869
|
+
const conversions = conversionsHint(plan);
|
|
38870
|
+
if (conversions) hints2.push(conversions);
|
|
38884
38871
|
if (plan.estimatedDays !== null && plan.estimatedDays > 60) {
|
|
38885
38872
|
hints2.push(
|
|
38886
38873
|
`At this page's traffic it would take about ${plan.estimatedDays} days. Consider testing a bigger change, which needs less traffic to detect.`
|
|
38887
38874
|
);
|
|
38888
38875
|
}
|
|
38889
|
-
if (
|
|
38876
|
+
if (horizons) hints2.push(horizons);
|
|
38890
38877
|
return hints2;
|
|
38891
38878
|
}
|
|
38892
38879
|
|
|
@@ -38901,45 +38888,7 @@ var experimentCompositionSchema = z35.object({
|
|
|
38901
38888
|
|
|
38902
38889
|
// ../api/src/experiments/goal.ts
|
|
38903
38890
|
import { z as z36 } from "zod";
|
|
38904
|
-
var
|
|
38905
|
-
var experimentGoalSchema = z36.discriminatedUnion("kind", [
|
|
38906
|
-
z36.object({
|
|
38907
|
-
kind: z36.literal("conversion"),
|
|
38908
|
-
/**
|
|
38909
|
-
* Narrow to one Form, by the slug that is its identity.
|
|
38910
|
-
*
|
|
38911
|
-
* Absent means every marked conversion on the page counts, which is the
|
|
38912
|
-
* right default for a landing with one Form and the wrong one for a page
|
|
38913
|
-
* carrying both a newsletter signup and a demo request.
|
|
38914
|
-
*/
|
|
38915
|
-
flowSlug: z36.string().max(120).optional()
|
|
38916
|
-
}),
|
|
38917
|
-
z36.object({
|
|
38918
|
-
kind: z36.literal("custom_event"),
|
|
38919
|
-
name: eventNameSchema,
|
|
38920
|
-
/**
|
|
38921
|
-
* Narrow to one value of one of the event's own properties.
|
|
38922
|
-
*
|
|
38923
|
-
* `section_view` on its own is every section; `section_view` where
|
|
38924
|
-
* `section = pricing` is the one that matters. Without this a page would
|
|
38925
|
-
* have to encode the answer into the event name to be testable against it,
|
|
38926
|
-
* which is precisely what the properties map exists to stop.
|
|
38927
|
-
*/
|
|
38928
|
-
property: z36.string().max(64).optional(),
|
|
38929
|
-
value: z36.string().max(255).optional()
|
|
38930
|
-
}),
|
|
38931
|
-
z36.object({
|
|
38932
|
-
kind: z36.literal("outbound_click"),
|
|
38933
|
-
/**
|
|
38934
|
-
* The destination host, as the row records it. Absent means any exit.
|
|
38935
|
-
*
|
|
38936
|
-
* A host rather than a full URL because that is what `event_name` carries
|
|
38937
|
-
* on an `outbound_click` row — `target_url` has the rest, and matching on
|
|
38938
|
-
* it would make the goal depend on query parameters a campaign rewrites.
|
|
38939
|
-
*/
|
|
38940
|
-
targetHost: z36.string().max(255).optional()
|
|
38941
|
-
})
|
|
38942
|
-
]);
|
|
38891
|
+
var experimentGoalSchema = z36.string().trim().min(1).max(MAX_CONVERSION_NAME);
|
|
38943
38892
|
|
|
38944
38893
|
// ../api/src/experiments/hypothesis.ts
|
|
38945
38894
|
import { z as z37 } from "zod";
|
|
@@ -38997,6 +38946,14 @@ var slugSchema = z38.string().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
|
38997
38946
|
var experimentPlanRequestSchema = z38.object({
|
|
38998
38947
|
landingSlug: slugSchema,
|
|
38999
38948
|
variantSlug: slugSchema,
|
|
38949
|
+
/**
|
|
38950
|
+
* Which of the company's conversions the test is about.
|
|
38951
|
+
*
|
|
38952
|
+
* Optional only when the company counts exactly one outcome, in which case
|
|
38953
|
+
* it is that one. A company counting several has to say which — a test that
|
|
38954
|
+
* "measures conversions" on a page with a newsletter box and a demo request
|
|
38955
|
+
* is measuring two things and deciding on their sum.
|
|
38956
|
+
*/
|
|
39000
38957
|
goal: experimentGoalSchema.optional(),
|
|
39001
38958
|
/**
|
|
39002
38959
|
* The smallest lift worth detecting, relative to the baseline. `0.2` is +20%.
|
|
@@ -39006,18 +38963,7 @@ var experimentPlanRequestSchema = z38.object({
|
|
|
39006
38963
|
* quarter of a million visitors per arm; `plan` will say so rather than
|
|
39007
38964
|
* running it.
|
|
39008
38965
|
*/
|
|
39009
|
-
minDetectableRelativeLift: z38.number().gt(0).max(10).optional()
|
|
39010
|
-
/**
|
|
39011
|
-
* "I have this many days — what could I even see?"
|
|
39012
|
-
*
|
|
39013
|
-
* Read-only, and deliberately not a setting. It does NOT shorten the test:
|
|
39014
|
-
* the finish line is a number of visitors fixed before the test starts, and a
|
|
39015
|
-
* horizon that can be moved is not a horizon. What it does is answer the
|
|
39016
|
-
* question a refusal leaves hanging — `plan` says a +20% lift needs three
|
|
39017
|
-
* weeks this page will not deliver, and this says what those weeks WOULD
|
|
39018
|
-
* settle, which is a change somebody can actually go and make.
|
|
39019
|
-
*/
|
|
39020
|
-
days: z38.number().int().gt(0).max(365).optional()
|
|
38966
|
+
minDetectableRelativeLift: z38.number().gt(0).max(10).optional()
|
|
39021
38967
|
});
|
|
39022
38968
|
var experimentStartRequestSchema = experimentPlanRequestSchema.extend({
|
|
39023
38969
|
hypothesis: experimentHypothesisSchema,
|
|
@@ -39032,28 +38978,13 @@ var experimentStartRequestSchema = experimentPlanRequestSchema.extend({
|
|
|
39032
38978
|
*/
|
|
39033
38979
|
composition: experimentCompositionSchema.optional()
|
|
39034
38980
|
});
|
|
38981
|
+
var experimentKeepSchema = z38.enum(["original", "variant"]);
|
|
39035
38982
|
var experimentFinishRequestSchema = z38.object({
|
|
39036
38983
|
experimentId: z38.string().min(1).max(64),
|
|
39037
|
-
|
|
39038
|
-
|
|
39039
|
-
|
|
39040
|
-
|
|
39041
|
-
* `keep_running`, and it is deliberately its own field rather than a
|
|
39042
|
-
* `survivor` they get to pick: an abandoned test has no winner, keeps the
|
|
39043
|
-
* control, and is recorded as `abandoned` so it can never be reported later
|
|
39044
|
-
* as a result. Without the separate flag, "stop this" and "the variant won"
|
|
39045
|
-
* would be the same call.
|
|
39046
|
-
*
|
|
39047
|
-
* **It overrides the verdict, and does not defer to it.** The only outcome it
|
|
39048
|
-
* can actually disagree with is a variant win — every other one keeps the
|
|
39049
|
-
* control anyway — so reading it as "apply only when the numbers have nothing
|
|
39050
|
-
* to say" meant that abandoning a won test handed all the traffic to the
|
|
39051
|
-
* variant instead of keeping the original: the opposite of what this field
|
|
39052
|
-
* promises, on live visitors, from a flag whose whole purpose is to be the
|
|
39053
|
-
* conservative choice. The response reports `discardedVerdict` when it did
|
|
39054
|
-
* override one, because throwing away a real finding should be said out loud.
|
|
39055
|
-
*/
|
|
39056
|
-
abandon: z38.boolean().optional()
|
|
38984
|
+
keep: experimentKeepSchema
|
|
38985
|
+
});
|
|
38986
|
+
var experimentPauseRequestSchema = z38.object({
|
|
38987
|
+
experimentId: z38.string().min(1).max(64)
|
|
39057
38988
|
});
|
|
39058
38989
|
var experimentFoldRequestSchema = z38.object({
|
|
39059
38990
|
/** Which test to fold. Omitted ⇒ every fold this company owes. */
|
|
@@ -39081,10 +39012,26 @@ var experimentStatusRequestSchema = z38.object({
|
|
|
39081
39012
|
experimentId: z38.string().max(64).optional(),
|
|
39082
39013
|
full: z38.boolean().optional()
|
|
39083
39014
|
});
|
|
39015
|
+
var experimentPageConversionSchema = z38.object({
|
|
39016
|
+
name: z38.string(),
|
|
39017
|
+
/** Distinct visitors to the page who went on to do this. */
|
|
39018
|
+
convertingVisitors: z38.number()
|
|
39019
|
+
});
|
|
39020
|
+
var experimentHorizonSchema = z38.object({
|
|
39021
|
+
days: z38.number(),
|
|
39022
|
+
/** The smallest relative lift detectable in that many days. `null` when none would be. */
|
|
39023
|
+
detectableLift: z38.number().nullable()
|
|
39024
|
+
});
|
|
39084
39025
|
var experimentPlanResponseSchema = z38.object({
|
|
39085
39026
|
canRun: z38.boolean(),
|
|
39086
39027
|
/** Present when `canRun` is false. Product language, ready to show. */
|
|
39087
39028
|
reason: z38.string().optional(),
|
|
39029
|
+
/** The conversion the test would be read on. Absent when none could be chosen. */
|
|
39030
|
+
goal: experimentGoalSchema.optional(),
|
|
39031
|
+
/** Every conversion this page produced over the sizing window, most common first. */
|
|
39032
|
+
conversions: z38.array(experimentPageConversionSchema),
|
|
39033
|
+
/** Distinct visitors the page had over the sizing window. */
|
|
39034
|
+
visitors: z38.number(),
|
|
39088
39035
|
baselineRate: z38.number(),
|
|
39089
39036
|
minDetectableRelativeLift: z38.number(),
|
|
39090
39037
|
/** `null` when no amount of traffic could settle the hypothesis. */
|
|
@@ -39093,14 +39040,7 @@ var experimentPlanResponseSchema = z38.object({
|
|
|
39093
39040
|
dailyVisitorsPerVariant: z38.number(),
|
|
39094
39041
|
/** `null` when the page has no traffic to project from. */
|
|
39095
39042
|
estimatedDays: z38.number().nullable(),
|
|
39096
|
-
|
|
39097
|
-
goalLabel: z38.string(),
|
|
39098
|
-
/**
|
|
39099
|
-
* The smallest lift `days` of this page's traffic could detect, when `days`
|
|
39100
|
-
* was asked for. `null` when it was not, or when no lift at all would be
|
|
39101
|
-
* detectable in that window.
|
|
39102
|
-
*/
|
|
39103
|
-
detectableLiftInDays: z38.number().nullable().optional()
|
|
39043
|
+
horizons: z38.array(experimentHorizonSchema)
|
|
39104
39044
|
});
|
|
39105
39045
|
var experimentVerdictSchema = z38.enum([
|
|
39106
39046
|
"keep_running",
|
|
@@ -39109,16 +39049,17 @@ var experimentVerdictSchema = z38.enum([
|
|
|
39109
39049
|
"invalid",
|
|
39110
39050
|
"stopped_early_harmful",
|
|
39111
39051
|
/**
|
|
39112
|
-
*
|
|
39113
|
-
* rule — only by `
|
|
39052
|
+
* Ended before the numbers could say anything. Never produced by the
|
|
39053
|
+
* stopping rule — only by ending a `keep_running` test.
|
|
39114
39054
|
*
|
|
39115
39055
|
* Its own verdict rather than folded into `no_difference`, because they are
|
|
39116
39056
|
* opposite claims: one says the two versions were measured and found alike,
|
|
39117
39057
|
* the other says nobody ever found out. Collapsing them would manufacture the
|
|
39118
39058
|
* exact finding this feature exists to refuse.
|
|
39119
39059
|
*/
|
|
39120
|
-
"
|
|
39060
|
+
"inconclusive"
|
|
39121
39061
|
]);
|
|
39062
|
+
var experimentStatusValueSchema = z38.enum(["running", "paused", "finished"]);
|
|
39122
39063
|
var experimentArmSchema = z38.object({
|
|
39123
39064
|
slug: z38.string(),
|
|
39124
39065
|
visitors: z38.number(),
|
|
@@ -39134,6 +39075,16 @@ var experimentArmSchema = z38.object({
|
|
|
39134
39075
|
*/
|
|
39135
39076
|
previewUrl: z38.string().nullable().optional()
|
|
39136
39077
|
});
|
|
39078
|
+
var experimentMetricSchema = z38.object({
|
|
39079
|
+
name: z38.string(),
|
|
39080
|
+
control: z38.object({ conversions: z38.number(), rate: z38.number() }),
|
|
39081
|
+
variant: z38.object({ conversions: z38.number(), rate: z38.number() }),
|
|
39082
|
+
relativeLift: z38.number(),
|
|
39083
|
+
/** The anytime-valid interval on the relative lift. */
|
|
39084
|
+
liftInterval: z38.tuple([z38.number(), z38.number()]),
|
|
39085
|
+
/** `better` when the interval sits above zero, `worse` below it, `unclear` across it. */
|
|
39086
|
+
reading: z38.enum(["better", "worse", "unclear"])
|
|
39087
|
+
});
|
|
39137
39088
|
var experimentTimelineArmSchema = z38.object({
|
|
39138
39089
|
visitors: z38.number(),
|
|
39139
39090
|
conversions: z38.number(),
|
|
@@ -39173,19 +39124,29 @@ var experimentStatusRowSchema = z38.object({
|
|
|
39173
39124
|
* is not a hypothesis, "leads should go up by at least 20%" is.
|
|
39174
39125
|
*/
|
|
39175
39126
|
minDetectableRelativeLift: z38.number(),
|
|
39176
|
-
|
|
39177
|
-
|
|
39127
|
+
/** The conversion the verdict is read from. The company's own name for it. */
|
|
39128
|
+
goal: experimentGoalSchema,
|
|
39129
|
+
status: experimentStatusValueSchema,
|
|
39178
39130
|
startedAt: z38.number(),
|
|
39131
|
+
/** Set while paused. The split is off and everyone sees the original. */
|
|
39132
|
+
pausedAt: z38.number().nullable(),
|
|
39179
39133
|
finishedAt: z38.number().nullable(),
|
|
39180
39134
|
verdict: experimentVerdictSchema,
|
|
39181
|
-
/** Set only on `winner`. Which
|
|
39135
|
+
/** Set only on `winner`. Which version the numbers favour. */
|
|
39182
39136
|
winner: z38.enum(["control", "variant"]).nullable(),
|
|
39137
|
+
/**
|
|
39138
|
+
* Which version is live, once the test has ended. Chosen by whoever ended it,
|
|
39139
|
+
* and allowed to disagree with `winner` — the card says so when it does.
|
|
39140
|
+
*/
|
|
39141
|
+
survivor: z38.enum(["control", "variant"]).nullable(),
|
|
39183
39142
|
/** Set only on `invalid`. Why the numbers cannot be read. */
|
|
39184
39143
|
invalidReason: z38.enum(["sample_ratio_mismatch", "crossover"]).nullable(),
|
|
39185
39144
|
/** What a person should be told, in one sentence, in product language. */
|
|
39186
39145
|
summary: z38.string(),
|
|
39187
39146
|
control: experimentArmSchema,
|
|
39188
39147
|
variant: experimentArmSchema,
|
|
39148
|
+
/** Every other conversion the company counts, on the same two arms. */
|
|
39149
|
+
secondary: z38.array(experimentMetricSchema),
|
|
39189
39150
|
/**
|
|
39190
39151
|
* How close this test is to being able to say anything at all, 0 to 1.
|
|
39191
39152
|
*
|
|
@@ -39199,8 +39160,8 @@ var experimentStatusRowSchema = z38.object({
|
|
|
39199
39160
|
earliestDecisionAt: z38.number().nullable(),
|
|
39200
39161
|
/**
|
|
39201
39162
|
* True when no amount of this page's traffic would settle the comparison.
|
|
39202
|
-
* A different thing from a page that is merely slow, and the reason to
|
|
39203
|
-
*
|
|
39163
|
+
* A different thing from a page that is merely slow, and the reason to end
|
|
39164
|
+
* a test rather than leave it running.
|
|
39204
39165
|
*/
|
|
39205
39166
|
willNotConclude: z38.boolean(),
|
|
39206
39167
|
/**
|
|
@@ -39225,6 +39186,13 @@ var experimentStatusRowSchema = z38.object({
|
|
|
39225
39186
|
var experimentStatusResponseSchema = z38.object({
|
|
39226
39187
|
experiments: z38.array(experimentStatusRowSchema)
|
|
39227
39188
|
});
|
|
39189
|
+
var experimentFinishResponseSchema = z38.object({
|
|
39190
|
+
verdict: experimentVerdictSchema,
|
|
39191
|
+
survivor: z38.enum(["control", "variant"]),
|
|
39192
|
+
/** True when `keep` went against a verdict the numbers had reached. */
|
|
39193
|
+
againstVerdict: z38.boolean(),
|
|
39194
|
+
summary: z38.string()
|
|
39195
|
+
});
|
|
39228
39196
|
|
|
39229
39197
|
// src/commands/experiment/hypothesis.ts
|
|
39230
39198
|
var SOURCES = experimentEvidenceSourceSchema.options.join(", ");
|
|
@@ -39262,7 +39230,7 @@ function parseEvidence(raw) {
|
|
|
39262
39230
|
// src/commands/experiment/index.ts
|
|
39263
39231
|
var GOAL_ARG = {
|
|
39264
39232
|
type: "string",
|
|
39265
|
-
description: "
|
|
39233
|
+
description: "Which of this company's conversions the test is about, by the name on the Conversions screen \u2014 \u201CBooked a call\u201D, \u201CQuote requested\u201D. Fixed for the life of the test. Optional when the company counts exactly one outcome; required when it counts several, and `plan` lists them. Every other conversion is reported beside the goal on every result and decides nothing. If the outcome you need is not defined yet, define it first with `baker analytics conversions` \u2014 it applies to history, so no traffic is lost.",
|
|
39266
39234
|
required: false
|
|
39267
39235
|
};
|
|
39268
39236
|
var LIFT_ARG = {
|
|
@@ -39270,11 +39238,6 @@ var LIFT_ARG = {
|
|
|
39270
39238
|
description: "The smallest improvement worth detecting, relative \u2014 0.2 is +20% (default). The single most consequential number here: halving it roughly quadruples the traffic needed. Ask for the smallest lift that would actually change what the client does, not the smallest one you would like to see.",
|
|
39271
39239
|
required: false
|
|
39272
39240
|
};
|
|
39273
|
-
var DAYS_ARG = {
|
|
39274
|
-
type: "string",
|
|
39275
|
-
description: "How many days you have, if that is the constraint. This does NOT shorten the test \u2014 the finish line is a visitor count fixed before it starts \u2014 it answers the other question: what a window that long could settle on this page's traffic. Use it when `plan` refuses and you need to know what WOULD fit.",
|
|
39276
|
-
required: false
|
|
39277
|
-
};
|
|
39278
39241
|
var BECAUSE_ARG = {
|
|
39279
39242
|
type: "string",
|
|
39280
39243
|
description: "What you SAW that makes this worth testing \u2014 the observation, not the change. \u201C60% of visitors never scroll past the hero\u201D, \u201CCPL doubled after the new headline\u201D. This is the field that makes a losing test worth something: without it there is no belief for the result to be evidence against, and the next test is no better informed than this one.",
|
|
@@ -39295,6 +39258,7 @@ var EVIDENCE_ARG = {
|
|
|
39295
39258
|
description: "Where the observation came from, as `source:what it showed`, optionally `|link`. Repeatable. Sources: baker_analytics, clarity, ads, search_console, research, inspiration, client, hunch. Use `hunch` honestly when there is no data \u2014 a stated guess is worth more than a guess dressed as analytics.",
|
|
39296
39259
|
required: false
|
|
39297
39260
|
};
|
|
39261
|
+
var ID_ARG = { type: "string", description: "The test, by its id", required: true };
|
|
39298
39262
|
function fail5(message) {
|
|
39299
39263
|
writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
|
|
39300
39264
|
process.exit(1);
|
|
@@ -39311,40 +39275,31 @@ registerSchema({
|
|
|
39311
39275
|
description: "Whether a page has enough traffic to settle a question, before anything is built",
|
|
39312
39276
|
args: {
|
|
39313
39277
|
landing: { type: "string", description: "The page under test, by slug", required: true },
|
|
39314
|
-
variant: { type: "string", description: "The
|
|
39278
|
+
variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
|
|
39315
39279
|
goal: GOAL_ARG,
|
|
39316
|
-
lift: LIFT_ARG
|
|
39317
|
-
days: DAYS_ARG
|
|
39280
|
+
lift: LIFT_ARG
|
|
39318
39281
|
}
|
|
39319
39282
|
});
|
|
39320
39283
|
var planCommand = defineCommand119({
|
|
39321
39284
|
meta: {
|
|
39322
39285
|
name: "plan",
|
|
39323
|
-
description: "Start here, BEFORE building the alternative page. Says whether this page gets enough traffic to settle the question, how long it would take,
|
|
39286
|
+
description: "Start here, BEFORE building the alternative page. Says whether this page gets enough traffic to settle the question, how long it would take, which conversions the page's visitors actually go on to do, and what a 14, 28, 56 or 90-day window could settle. Most landing pages cannot run most tests \u2014 finding that out now costs one call, and finding it out later costs three weeks and a decision made on noise."
|
|
39324
39287
|
},
|
|
39325
39288
|
args: {
|
|
39326
39289
|
landing: { type: "string", description: "The page under test, by slug", required: true },
|
|
39327
|
-
variant: { type: "string", description: "The
|
|
39290
|
+
variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
|
|
39328
39291
|
goal: GOAL_ARG,
|
|
39329
|
-
lift: LIFT_ARG
|
|
39330
|
-
days: DAYS_ARG
|
|
39292
|
+
lift: LIFT_ARG
|
|
39331
39293
|
},
|
|
39332
39294
|
run: async ({ args }) => {
|
|
39333
|
-
const goal = parseGoal(args.goal ? String(args.goal) : void 0);
|
|
39334
|
-
if (goal && "error" in goal) fail5(goal.error);
|
|
39335
39295
|
try {
|
|
39336
39296
|
const response = await apiPost("/api/experiments/plan", {
|
|
39337
39297
|
landingSlug: String(args.landing),
|
|
39338
39298
|
variantSlug: String(args.variant),
|
|
39339
|
-
...goal ? { goal } : {},
|
|
39340
|
-
...args.lift ? { minDetectableRelativeLift: Number(args.lift) } : {}
|
|
39341
|
-
...args.days ? { days: Number(args.days) } : {}
|
|
39342
|
-
});
|
|
39343
|
-
writeJsonEnvelope({
|
|
39344
|
-
ok: true,
|
|
39345
|
-
data: response.data,
|
|
39346
|
-
hints: buildPlanHints(response.data, args.days ? Number(args.days) : void 0)
|
|
39299
|
+
...args.goal ? { goal: String(args.goal) } : {},
|
|
39300
|
+
...args.lift ? { minDetectableRelativeLift: Number(args.lift) } : {}
|
|
39347
39301
|
});
|
|
39302
|
+
writeJsonEnvelope({ ok: true, data: response.data, hints: buildPlanHints(response.data) });
|
|
39348
39303
|
} catch (error) {
|
|
39349
39304
|
reportApiError(error);
|
|
39350
39305
|
}
|
|
@@ -39352,10 +39307,10 @@ var planCommand = defineCommand119({
|
|
|
39352
39307
|
});
|
|
39353
39308
|
registerSchema({
|
|
39354
39309
|
command: "experiment.start",
|
|
39355
|
-
description: "Stage an A/B test between a page and
|
|
39310
|
+
description: "Stage an A/B test between a page and a new variant of it",
|
|
39356
39311
|
args: {
|
|
39357
39312
|
landing: { type: "string", description: "The page under test, by slug", required: true },
|
|
39358
|
-
variant: { type: "string", description: "The
|
|
39313
|
+
variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
|
|
39359
39314
|
because: BECAUSE_ARG,
|
|
39360
39315
|
change: CHANGE_ARG,
|
|
39361
39316
|
expect: EXPECT_ARG,
|
|
@@ -39367,11 +39322,11 @@ registerSchema({
|
|
|
39367
39322
|
var startCommand = defineCommand119({
|
|
39368
39323
|
meta: {
|
|
39369
39324
|
name: "start",
|
|
39370
|
-
description: "Stage a test between the page and its
|
|
39325
|
+
description: "Stage a test between the page and its new variant. Visitors are split 50/50 at the edge, so there is no flicker and nothing for an ad blocker to suppress. Nothing goes live until the session is published \u2014 the person publishing is the review of the new variant. Re-runs `plan` first and refuses on the same grounds."
|
|
39371
39326
|
},
|
|
39372
39327
|
args: {
|
|
39373
39328
|
landing: { type: "string", description: "The page under test, by slug", required: true },
|
|
39374
|
-
variant: { type: "string", description: "The
|
|
39329
|
+
variant: { type: "string", description: "The new variant's slug, `<page>--<n>`", required: true },
|
|
39375
39330
|
because: BECAUSE_ARG,
|
|
39376
39331
|
change: CHANGE_ARG,
|
|
39377
39332
|
expect: EXPECT_ARG,
|
|
@@ -39380,8 +39335,6 @@ var startCommand = defineCommand119({
|
|
|
39380
39335
|
lift: LIFT_ARG
|
|
39381
39336
|
},
|
|
39382
39337
|
run: async ({ args }) => {
|
|
39383
|
-
const goal = parseGoal(args.goal ? String(args.goal) : void 0);
|
|
39384
|
-
if (goal && "error" in goal) fail5(goal.error);
|
|
39385
39338
|
const expect = args.expect === void 0 ? "increase" : String(args.expect);
|
|
39386
39339
|
if (expect !== "increase" && expect !== "decrease") {
|
|
39387
39340
|
fail5("`--expect` is either `increase` or `decrease` \u2014 which way should the goal move if you are right?");
|
|
@@ -39400,7 +39353,7 @@ var startCommand = defineCommand119({
|
|
|
39400
39353
|
...evidence ? { evidence } : {}
|
|
39401
39354
|
},
|
|
39402
39355
|
...composition ? { composition } : {},
|
|
39403
|
-
...goal ? { goal } : {},
|
|
39356
|
+
...args.goal ? { goal: String(args.goal) } : {},
|
|
39404
39357
|
...args.lift ? { minDetectableRelativeLift: Number(args.lift) } : {}
|
|
39405
39358
|
});
|
|
39406
39359
|
writeJsonEnvelope({
|
|
@@ -39411,9 +39364,9 @@ var startCommand = defineCommand119({
|
|
|
39411
39364
|
// with no `--fork` succeeds and produces a page that renders exactly
|
|
39412
39365
|
// the control, so the test would run for weeks against itself.
|
|
39413
39366
|
...composition && composition.forked.length === 0 ? [
|
|
39414
|
-
`\u201C${String(args.variant)}\u201D does not have its own copy of any section \u2014 it renders exactly the same page as \u201C${String(args.landing)}\u201D, so this test cannot find anything. Fork the section you want to test (\`baker landing variant ${String(args.landing)}
|
|
39367
|
+
`\u201C${String(args.variant)}\u201D does not have its own copy of any section \u2014 it renders exactly the same page as \u201C${String(args.landing)}\u201D, so this test cannot find anything. Fork the section you want to test (\`baker landing variant ${String(args.landing)} --fork Hero.astro\`) and edit only that file.`
|
|
39415
39368
|
] : [],
|
|
39416
|
-
"Staged. The split starts when this session is published \u2014 publishing is the review of the
|
|
39369
|
+
"Staged. The split starts when this session is published \u2014 publishing is the review of the new variant.",
|
|
39417
39370
|
"Check it as often as you like \u2014 `baker experiment status` is safe to read at any moment and holds its error rate however often you ask. What is never safe is reading the numbers instead of the verdict: `keep_running` means this test cannot yet tell the two pages apart, whatever the rates happen to say today."
|
|
39418
39371
|
]
|
|
39419
39372
|
});
|
|
@@ -39424,7 +39377,7 @@ var startCommand = defineCommand119({
|
|
|
39424
39377
|
});
|
|
39425
39378
|
registerSchema({
|
|
39426
39379
|
command: "experiment.status",
|
|
39427
|
-
description: "The verdict on every A/B test, running and finished",
|
|
39380
|
+
description: "The verdict on every A/B test, running, paused and finished",
|
|
39428
39381
|
args: {
|
|
39429
39382
|
id: { type: "string", description: "One test, by its id", required: false },
|
|
39430
39383
|
full: { type: "boolean", description: "Include the posteriors behind the verdict", required: false }
|
|
@@ -39433,7 +39386,7 @@ registerSchema({
|
|
|
39433
39386
|
var statusCommand6 = defineCommand119({
|
|
39434
39387
|
meta: {
|
|
39435
39388
|
name: "status",
|
|
39436
|
-
description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference | invalid | stopped_early_harmful. `summary` is one sentence you can show the client. The numbers under `--full` are for printing, never for deciding \u2014 a test that says keep_running has not finished, however good its numbers look."
|
|
39389
|
+
description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference | invalid | stopped_early_harmful. `summary` is one sentence you can show the client, and `secondary` lists every other conversion the company counts on the same two arms \u2014 context, never a second decision. The numbers under `--full` are for printing, never for deciding \u2014 a test that says keep_running has not finished, however good its numbers look."
|
|
39437
39390
|
},
|
|
39438
39391
|
args: {
|
|
39439
39392
|
id: { type: "string", description: "One test, by its id", required: false },
|
|
@@ -39457,44 +39410,88 @@ var statusCommand6 = defineCommand119({
|
|
|
39457
39410
|
});
|
|
39458
39411
|
registerSchema({
|
|
39459
39412
|
command: "experiment.finish",
|
|
39460
|
-
description: "End a test and send all its traffic to the
|
|
39413
|
+
description: "End a test now and send all its traffic to the version you name",
|
|
39461
39414
|
args: {
|
|
39462
|
-
id:
|
|
39463
|
-
|
|
39464
|
-
type: "
|
|
39465
|
-
description: "
|
|
39466
|
-
required:
|
|
39415
|
+
id: ID_ARG,
|
|
39416
|
+
keep: {
|
|
39417
|
+
type: "string",
|
|
39418
|
+
description: "Which version stays live: `original` or `variant`. Required \u2014 the verdict is a recommendation and this is the decision. A test that has not concluded can be ended too; it is recorded as inconclusive, never as a finding. Going against a verdict is allowed and is recorded as such.",
|
|
39419
|
+
required: true
|
|
39467
39420
|
}
|
|
39468
39421
|
}
|
|
39469
39422
|
});
|
|
39470
39423
|
var finishCommand = defineCommand119({
|
|
39471
39424
|
meta: {
|
|
39472
39425
|
name: "finish",
|
|
39473
|
-
description: "End a test and send all its traffic to
|
|
39426
|
+
description: "End a test now and send all its traffic to the version you name. Takes effect within seconds \u2014 nothing to publish. Read `baker experiment status` first: `winner` says which version the numbers favour, `keep_running` means they have not spoken yet and ending records `inconclusive`. Keeping the variant owes a `fold`. A test staged in this session and not yet published is simply taken back off it."
|
|
39474
39427
|
},
|
|
39475
39428
|
args: {
|
|
39476
|
-
id:
|
|
39477
|
-
|
|
39478
|
-
type: "
|
|
39479
|
-
description: "
|
|
39480
|
-
required:
|
|
39429
|
+
id: ID_ARG,
|
|
39430
|
+
keep: {
|
|
39431
|
+
type: "string",
|
|
39432
|
+
description: "Which version stays live: `original` or `variant`.",
|
|
39433
|
+
required: true
|
|
39481
39434
|
}
|
|
39482
39435
|
},
|
|
39483
39436
|
run: async ({ args }) => {
|
|
39437
|
+
const keep = String(args.keep);
|
|
39438
|
+
if (keep !== "original" && keep !== "variant") {
|
|
39439
|
+
fail5("`--keep` is either `original` or `variant` \u2014 which version should everyone see from now on?");
|
|
39440
|
+
}
|
|
39484
39441
|
try {
|
|
39485
|
-
const response = await apiPost(
|
|
39486
|
-
|
|
39487
|
-
|
|
39488
|
-
|
|
39442
|
+
const response = await apiPost(
|
|
39443
|
+
"/api/experiments/finish",
|
|
39444
|
+
{ experimentId: String(args.id), keep }
|
|
39445
|
+
);
|
|
39446
|
+
if ("unstaged" in response.data) {
|
|
39447
|
+
writeJsonEnvelope({
|
|
39448
|
+
ok: true,
|
|
39449
|
+
data: response.data,
|
|
39450
|
+
hints: ["That test had not been published yet, so it was taken back off this session. Nothing was split."]
|
|
39451
|
+
});
|
|
39452
|
+
return;
|
|
39453
|
+
}
|
|
39454
|
+
const data = response.data;
|
|
39489
39455
|
writeJsonEnvelope({
|
|
39490
39456
|
ok: true,
|
|
39491
|
-
data
|
|
39457
|
+
data,
|
|
39492
39458
|
hints: [
|
|
39493
|
-
`
|
|
39494
|
-
...
|
|
39495
|
-
`You
|
|
39459
|
+
`Done. Everyone now sees the ${data.survivor === "variant" ? "new variant" : "original"}.`,
|
|
39460
|
+
...data.againstVerdict ? [
|
|
39461
|
+
`You kept the ${data.survivor === "variant" ? "new variant" : "original"} although the numbers favoured the other version (${data.verdict}). That is recorded on the test \u2014 say why in the write-up.`
|
|
39462
|
+
] : [],
|
|
39463
|
+
...data.verdict === "keep_running" ? [
|
|
39464
|
+
"This test had not concluded, so it is recorded as inconclusive \u2014 nobody found out. Never report it as no difference."
|
|
39496
39465
|
] : [],
|
|
39497
|
-
|
|
39466
|
+
...data.survivor === "variant" ? [
|
|
39467
|
+
"Now run `baker experiment fold` \u2014 it moves the winning version into the page itself and archives the other one. Until that lands the page is served by a rewrite, and it cannot be tested again."
|
|
39468
|
+
] : []
|
|
39469
|
+
]
|
|
39470
|
+
});
|
|
39471
|
+
} catch (error) {
|
|
39472
|
+
reportApiError(error);
|
|
39473
|
+
}
|
|
39474
|
+
}
|
|
39475
|
+
});
|
|
39476
|
+
registerSchema({
|
|
39477
|
+
command: "experiment.pause",
|
|
39478
|
+
description: "Switch a test's split off \u2014 everyone sees the original until it is resumed",
|
|
39479
|
+
args: { id: ID_ARG }
|
|
39480
|
+
});
|
|
39481
|
+
var pauseCommand = defineCommand119({
|
|
39482
|
+
meta: {
|
|
39483
|
+
name: "pause",
|
|
39484
|
+
description: "Switch the split off now. Everyone sees the original; visitors already assigned keep their side, so resuming continues the same test rather than starting a new one. Use it when the new variant has to come down for a while \u2014 a broken price, a campaign that needs the original \u2014 without throwing the test away."
|
|
39485
|
+
},
|
|
39486
|
+
args: { id: ID_ARG },
|
|
39487
|
+
run: async ({ args }) => {
|
|
39488
|
+
try {
|
|
39489
|
+
await apiPost("/api/experiments/pause", { experimentId: String(args.id) });
|
|
39490
|
+
writeJsonEnvelope({
|
|
39491
|
+
ok: true,
|
|
39492
|
+
data: { paused: true },
|
|
39493
|
+
hints: [
|
|
39494
|
+
`Paused. Everyone sees the original page now. \`baker experiment resume --id ${String(args.id)}\` restarts the split with the same sides.`
|
|
39498
39495
|
]
|
|
39499
39496
|
});
|
|
39500
39497
|
} catch (error) {
|
|
@@ -39502,6 +39499,30 @@ var finishCommand = defineCommand119({
|
|
|
39502
39499
|
}
|
|
39503
39500
|
}
|
|
39504
39501
|
});
|
|
39502
|
+
registerSchema({
|
|
39503
|
+
command: "experiment.resume",
|
|
39504
|
+
description: "Switch a paused test's split back on",
|
|
39505
|
+
args: { id: ID_ARG }
|
|
39506
|
+
});
|
|
39507
|
+
var resumeCommand = defineCommand119({
|
|
39508
|
+
meta: {
|
|
39509
|
+
name: "resume",
|
|
39510
|
+
description: "Switch a paused test's split back on. Visitors already assigned see the same side they saw before."
|
|
39511
|
+
},
|
|
39512
|
+
args: { id: ID_ARG },
|
|
39513
|
+
run: async ({ args }) => {
|
|
39514
|
+
try {
|
|
39515
|
+
await apiPost("/api/experiments/resume", { experimentId: String(args.id) });
|
|
39516
|
+
writeJsonEnvelope({
|
|
39517
|
+
ok: true,
|
|
39518
|
+
data: { resumed: true },
|
|
39519
|
+
hints: ["Resumed. Half the visitors see the new variant again."]
|
|
39520
|
+
});
|
|
39521
|
+
} catch (error) {
|
|
39522
|
+
reportApiError(error);
|
|
39523
|
+
}
|
|
39524
|
+
}
|
|
39525
|
+
});
|
|
39505
39526
|
registerSchema({
|
|
39506
39527
|
command: "experiment.fold",
|
|
39507
39528
|
description: "Make the page's own content the version that won, and archive the one that lost",
|
|
@@ -39563,12 +39584,14 @@ var foldCommand = defineCommand119({
|
|
|
39563
39584
|
var experimentCommand = defineCommand119({
|
|
39564
39585
|
meta: {
|
|
39565
39586
|
name: "experiment",
|
|
39566
|
-
description: "Run an A/B test between a landing page and
|
|
39587
|
+
description: "Run an A/B test between a landing page and a new variant of it, and be told which one is better. Always `plan` first: most pages do not get enough traffic to settle most questions, and a test that cannot conclude still produces a number somebody will act on. Nothing ends a test on its own \u2014 `finish` does, when you or the client decide."
|
|
39567
39588
|
},
|
|
39568
39589
|
subCommands: {
|
|
39569
39590
|
plan: planCommand,
|
|
39570
39591
|
start: startCommand,
|
|
39571
39592
|
status: statusCommand6,
|
|
39593
|
+
pause: pauseCommand,
|
|
39594
|
+
resume: resumeCommand,
|
|
39572
39595
|
finish: finishCommand,
|
|
39573
39596
|
fold: foldCommand
|
|
39574
39597
|
}
|