Skip to content

Commit b97e870

Browse files
authored
Decouple args from context (#1643)
1 parent 927853f commit b97e870

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+655
-944
lines changed

scripts/generate-readme.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import { remark } from "remark";
66
import remarkGfm from "remark-gfm";
77
import remarkToc from "remark-toc";
88
import { dedent } from "ts-dedent";
9-
import { args, usage } from "../src/commands/root.js";
9+
import { usage } from "../src/commands/root.js";
1010
import { Commands, importCommand } from "../src/services/command/command.js";
1111
import { Context } from "../src/services/command/context.js";
1212

13-
const ctx = Context.init({ name: "readme", parse: args, argv: ["-h"] });
13+
const ctx = Context.init({ name: "readme" });
1414
let readme = await fs.readFile("README.md", "utf8");
1515

1616
readme = readme.replace(

spec/__support__/arg.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { expect } from "vitest";
2+
import * as root from "../../src/commands/root.js";
3+
import { parseArgs, type ArgsDefinition, type ArgsDefinitionResult } from "../../src/services/command/arg.js";
4+
import { Commands, setCurrentCommand, type Command } from "../../src/services/command/command.js";
5+
import { testCtx } from "./context.js";
6+
7+
export const makeRootArgs = (...argv: string[]): root.RootArgsResult => {
8+
return parseArgs(root.args, { argv, permissive: true });
9+
};
10+
11+
export const makeArgs = <Args extends ArgsDefinition>(args: Args, ...argv: string[]): ArgsDefinitionResult<Args> => {
12+
const rootArgs = makeRootArgs(...argv);
13+
14+
// replicate the root command's behavior of shifting the command name
15+
const commandName = rootArgs._.shift() as Command | undefined;
16+
if (commandName) {
17+
// ensure the command was valid
18+
expect(Commands).toContain(commandName);
19+
setCurrentCommand(testCtx, commandName);
20+
}
21+
22+
return parseArgs(args, { argv: rootArgs._ });
23+
};

spec/__support__/context.ts

+2-46
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
1-
import { beforeEach, expect } from "vitest";
2-
import { args } from "../../src/commands/root.js";
3-
import type { ArgsDefinition } from "../../src/services/command/arg.js";
4-
import { Commands, setCurrentCommand, type Command } from "../../src/services/command/command.js";
1+
import { beforeEach } from "vitest";
52
import { Context } from "../../src/services/command/context.js";
63

74
/**
8-
* The current test's context.
9-
*
10-
* All contexts made by {@linkcode makeRootContext} and
11-
* {@linkcode makeContext} are children of this context.
5+
* A {@linkcode Context} that is set up before each test.
126
*/
137
export let testCtx: Context;
148

@@ -23,41 +17,3 @@ export const mockContext = (): void => {
2317
};
2418
});
2519
};
26-
27-
/**
28-
* Makes a root context the same way the root command does.
29-
*/
30-
export const makeRootContext = (): Context => {
31-
return testCtx.child({
32-
name: "root",
33-
parse: args,
34-
argv: process.argv.slice(2),
35-
permissive: true,
36-
});
37-
};
38-
39-
/**
40-
* Makes a context the same way the root command would before passing it
41-
* to a subcommand.
42-
*/
43-
// TODO: make this take an AvailableCommand and use it to type which `parse` must be passed
44-
export const makeContext = <Args extends ArgsDefinition>({
45-
parse = {} as Args,
46-
argv,
47-
}: { parse?: Args; argv?: string[] } = {}): Context<Args> => {
48-
if (argv) {
49-
process.argv = ["node", "/some/path/to/ggt.js", ...argv];
50-
}
51-
52-
const ctx = makeRootContext();
53-
54-
// replicate the root command's behavior of shifting the command name
55-
const commandName = ctx.args._.shift() as Command | undefined;
56-
if (commandName) {
57-
// ensure the command was valid
58-
expect(Commands).toContain(commandName);
59-
setCurrentCommand(ctx, commandName);
60-
}
61-
62-
return ctx.child({ name: commandName, parse });
63-
};

spec/__support__/filesync.ts

+20-32
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
type FileSyncDeletedEventInput,
1212
type MutationPublishFileSyncEventsArgs,
1313
} from "../../src/__generated__/graphql.js";
14-
import { args, type DevArgs } from "../../src/commands/dev.js";
1514
import { getCurrentApp, getCurrentEnv } from "../../src/services/app/context.js";
1615
import {
1716
FILE_SYNC_COMPARISON_HASHES_QUERY,
@@ -20,19 +19,19 @@ import {
2019
PUBLISH_FILE_SYNC_EVENTS_MUTATION,
2120
REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION,
2221
} from "../../src/services/app/edit/operation.js";
23-
import type { Context } from "../../src/services/command/context.js";
2422
import { Directory, swallowEnoent, type Hashes } from "../../src/services/filesync/directory.js";
2523
import type { File } from "../../src/services/filesync/file.js";
2624
import { FileSync } from "../../src/services/filesync/filesync.js";
2725
import { isEqualHashes } from "../../src/services/filesync/hashes.js";
28-
import { SyncJson, type SyncJsonArgs, type SyncJsonState } from "../../src/services/filesync/sync-json.js";
26+
import { SyncJson, SyncJsonArgs, type SyncJsonArgsResult, type SyncJsonState } from "../../src/services/filesync/sync-json.js";
2927
import { noop } from "../../src/services/util/function.js";
3028
import { isNil } from "../../src/services/util/is.js";
3129
import { defaults } from "../../src/services/util/object.js";
3230
import { PromiseSignal } from "../../src/services/util/promise.js";
3331
import type { PartialExcept } from "../../src/services/util/types.js";
3432
import { testApp } from "./app.js";
35-
import { makeContext } from "./context.js";
33+
import { makeArgs } from "./arg.js";
34+
import { testCtx } from "./context.js";
3635
import { assertOrFail, log } from "./debug.js";
3736
import { readDir, writeDir, type Files } from "./files.js";
3837
import { makeMockEditSubscriptions, nockEditResponse, type MockEditSubscription } from "./graphql.js";
@@ -41,13 +40,8 @@ import { mock, mockRestore } from "./mock.js";
4140
import { testDirPath } from "./paths.js";
4241
import { timeoutMs } from "./sleep.js";
4342

44-
export type FileSyncScenarioOptions<Args extends SyncJsonArgs = DevArgs> = {
45-
/**
46-
* The context to use for the {@linkcode SyncJson} instance.
47-
*
48-
* @default makeContext(args, ["dev", localDir.path, `--app=${testApp.slug}`, `--env=${testApp.environments[0]!.name}`])
49-
*/
50-
ctx?: Context<Args>;
43+
export type FileSyncScenarioOptions = {
44+
args?: SyncJsonArgsResult;
5145

5246
/**
5347
* The files at filesVersion 1.
@@ -83,9 +77,7 @@ export type FileSyncScenarioOptions<Args extends SyncJsonArgs = DevArgs> = {
8377
afterPublishFileSyncEvents?: () => Promisable<void>;
8478
};
8579

86-
export type SyncScenario<Args extends SyncJsonArgs = DevArgs> = {
87-
ctx: Context<Args>;
88-
80+
export type SyncScenario = {
8981
/**
9082
* The {@linkcode SyncJson} instance the {@linkcode FileSync} instance
9183
* is using.
@@ -175,18 +167,15 @@ export type SyncScenario<Args extends SyncJsonArgs = DevArgs> = {
175167
* @see {@linkcode FileSyncScenarioOptions}
176168
* @see {@linkcode SyncScenario}
177169
*/
178-
export const makeSyncScenario = async <Args extends SyncJsonArgs = DevArgs>({
179-
ctx,
170+
export const makeSyncScenario = async ({
171+
args,
180172
filesVersion1Files,
181173
localFiles,
182174
gadgetFiles,
183175
beforePublishFileSyncEvents,
184176
afterPublishFileSyncEvents,
185-
}: Partial<FileSyncScenarioOptions<Args>> = {}): Promise<SyncScenario<Args>> => {
186-
ctx ??= makeContext({
187-
parse: args,
188-
argv: ["dev", testDirPath("local"), `--app=${testApp.slug}`, `--env=${testApp.environments[0]!.name}`],
189-
}) as Context<Args>;
177+
}: Partial<FileSyncScenarioOptions> = {}): Promise<SyncScenario> => {
178+
args ??= makeArgs(SyncJsonArgs, "dev", testDirPath("local"), `--app=${testApp.slug}`, `--env=${testApp.environments[0]!.name}`);
190179

191180
let environmentFilesVersion = 1n;
192181
await writeDir(testDirPath("gadget"), { ".gadget/": "", ...gadgetFiles });
@@ -198,7 +187,7 @@ export const makeSyncScenario = async <Args extends SyncJsonArgs = DevArgs>({
198187
const filesVersionDirs = new Map<bigint, Directory>();
199188
filesVersionDirs.set(1n, filesVersion1Dir);
200189

201-
if (!isEqualHashes(ctx, await gadgetDir.hashes(), await filesVersion1Dir.hashes())) {
190+
if (!isEqualHashes(testCtx, await gadgetDir.hashes(), await filesVersion1Dir.hashes())) {
202191
environmentFilesVersion = 2n;
203192
await fs.copy(gadgetDir.path, testDirPath("fv-2"));
204193
filesVersionDirs.set(2n, await Directory.init(testDirPath("fv-2")));
@@ -231,7 +220,7 @@ export const makeSyncScenario = async <Args extends SyncJsonArgs = DevArgs>({
231220
}
232221

233222
mockRestore(SyncJson.load);
234-
const syncJson = await SyncJson.loadOrInit(ctx, { directory: localDir });
223+
const syncJson = await SyncJson.loadOrInit(testCtx, { args, directory: localDir });
235224
mock(SyncJson, "load", () => syncJson);
236225

237226
const filesync = new FileSync(syncJson);
@@ -266,8 +255,8 @@ export const makeSyncScenario = async <Args extends SyncJsonArgs = DevArgs>({
266255
};
267256

268257
nockEditResponse({
269-
app: getCurrentApp(ctx),
270-
env: getCurrentEnv(ctx),
258+
app: getCurrentApp(testCtx),
259+
env: getCurrentEnv(testCtx),
271260
optional: true,
272261
persist: true,
273262
operation: FILE_SYNC_HASHES_QUERY,
@@ -300,8 +289,8 @@ export const makeSyncScenario = async <Args extends SyncJsonArgs = DevArgs>({
300289
});
301290

302291
nockEditResponse({
303-
app: getCurrentApp(ctx),
304-
env: getCurrentEnv(ctx),
292+
app: getCurrentApp(testCtx),
293+
env: getCurrentEnv(testCtx),
305294
optional: true,
306295
persist: true,
307296
operation: FILE_SYNC_COMPARISON_HASHES_QUERY,
@@ -332,8 +321,8 @@ export const makeSyncScenario = async <Args extends SyncJsonArgs = DevArgs>({
332321
});
333322

334323
nockEditResponse({
335-
app: getCurrentApp(ctx),
336-
env: getCurrentEnv(ctx),
324+
app: getCurrentApp(testCtx),
325+
env: getCurrentEnv(testCtx),
337326
optional: true,
338327
persist: true,
339328
operation: FILE_SYNC_FILES_QUERY,
@@ -374,8 +363,8 @@ export const makeSyncScenario = async <Args extends SyncJsonArgs = DevArgs>({
374363
});
375364

376365
nockEditResponse({
377-
app: getCurrentApp(ctx),
378-
env: getCurrentEnv(ctx),
366+
app: getCurrentApp(testCtx),
367+
env: getCurrentEnv(testCtx),
379368
optional: true,
380369
persist: true,
381370
operation: PUBLISH_FILE_SYNC_EVENTS_MUTATION,
@@ -421,7 +410,6 @@ export const makeSyncScenario = async <Args extends SyncJsonArgs = DevArgs>({
421410
const mockEditSubscriptions = makeMockEditSubscriptions();
422411

423412
return {
424-
ctx,
425413
syncJson,
426414
filesync,
427415
filesVersionDirs,

spec/commands/add.spec.ts

+16-41
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, it } from "vitest";
2-
import { run as addCommand, args } from "../../src/commands/add.js";
2+
import * as add from "../../src/commands/add.js";
33
import { GADGET_GLOBAL_ACTIONS_QUERY, GADGET_META_MODELS_QUERY } from "../../src/services/app/api/operation.js";
44
import {
55
CREATE_ACTION_MUTATION,
@@ -8,7 +8,8 @@ import {
88
CREATE_ROUTE_MUTATION,
99
} from "../../src/services/app/edit/operation.js";
1010
import { ArgError } from "../../src/services/command/arg.js";
11-
import { makeContext } from "../__support__/context.js";
11+
import { makeArgs } from "../__support__/arg.js";
12+
import { testCtx } from "../__support__/context.js";
1213
import { expectError } from "../__support__/error.js";
1314
import { makeSyncScenario } from "../__support__/filesync.js";
1415
import { nockApiResponse, nockEditResponse } from "../__support__/graphql.js";
@@ -28,9 +29,7 @@ describe("add", () => {
2829
expectVariables: { path: "modelA", fields: [] },
2930
});
3031

31-
const ctx = makeContext({ parse: args, argv: ["add", "model", "modelA"] });
32-
33-
await addCommand(ctx);
32+
await add.run(testCtx, makeArgs(add.args, "add", "model", "modelA"));
3433
});
3534

3635
it("can add a model with fields", async () => {
@@ -46,15 +45,11 @@ describe("add", () => {
4645
},
4746
});
4847

49-
const ctx = makeContext({ parse: args, argv: ["add", "model", "modelA", "newField:string", "newField2:boolean"] });
50-
51-
await addCommand(ctx);
48+
await add.run(testCtx, makeArgs(add.args, "add", "model", "modelA", "newField:string", "newField2:boolean"));
5249
});
5350

5451
it("requires a model path", async () => {
55-
const ctx = makeContext({ parse: args, argv: ["add", "model"] });
56-
57-
const error = await expectError(() => addCommand(ctx));
52+
const error = await expectError(() => add.run(testCtx, makeArgs(add.args, "add", "model")));
5853
expect(error).toBeInstanceOf(ArgError);
5954
expect(error.sprint()).toMatchInlineSnapshot(`
6055
"✘ Failed to add model, missing model path
@@ -65,9 +60,7 @@ describe("add", () => {
6560
});
6661

6762
it.each(["field;string", "field:", ":", ""])('returns ArgErrors when field argument is "%s"', async (invalidFieldArgument) => {
68-
const ctx = makeContext({ parse: args, argv: ["add", "model", "newModel", invalidFieldArgument] });
69-
70-
const error = await expectError(() => addCommand(ctx));
63+
const error = await expectError(() => add.run(testCtx, makeArgs(add.args, "add", "model", "modelA", invalidFieldArgument)));
7164
expect(error).toBeInstanceOf(ArgError);
7265
expect(error.sprint()).toContain("is not a valid field definition");
7366
});
@@ -84,15 +77,11 @@ describe("add", () => {
8477
},
8578
});
8679

87-
const ctx = makeContext({ parse: args, argv: ["add", "field", "modelA/newField:string"] });
88-
89-
await addCommand(ctx);
80+
await add.run(testCtx, makeArgs(add.args, "add", "field", "modelA/newField:string"));
9081
});
9182

9283
it("returns an ArgError if there's no input", async () => {
93-
const ctx = makeContext({ parse: args, argv: ["add", "field"] });
94-
95-
const error = await expectError(() => addCommand(ctx));
84+
const error = await expectError(() => add.run(testCtx, makeArgs(add.args, "add", "field")));
9685
expect(error).toBeInstanceOf(ArgError);
9786
expect(error.sprint()).toMatchInlineSnapshot(`
9887
"✘ Failed to add field, invalid field path definition
@@ -103,17 +92,13 @@ describe("add", () => {
10392
});
10493

10594
it.each(["user", "user/"])("returns missing field definition ArgError if the input is %s", async (partialInput) => {
106-
const ctx = makeContext({ parse: args, argv: ["add", "field", partialInput] });
107-
108-
const error = await expectError(() => addCommand(ctx));
95+
const error = await expectError(() => add.run(testCtx, makeArgs(add.args, "add", "field", partialInput)));
10996
expect(error).toBeInstanceOf(ArgError);
11097
expect(error.sprint()).toContain("Failed to add field, invalid field definition");
11198
});
11299

113100
it.each(["user/field", "user/field:", "user/:"])("returns missing field type ArgError if the input is %s", async (partialInput) => {
114-
const ctx = makeContext({ parse: args, argv: ["add", "field", partialInput] });
115-
116-
const error = await expectError(() => addCommand(ctx));
101+
const error = await expectError(() => add.run(testCtx, makeArgs(add.args, "add", "field", partialInput)));
117102
expect(error).toBeInstanceOf(ArgError);
118103
expect(error.sprint()).toContain("is not a valid field definition");
119104
});
@@ -167,15 +152,11 @@ describe("add", () => {
167152
expectVariables: { path: "actionA" },
168153
});
169154

170-
const ctx = makeContext({ parse: args, argv: ["add", "action", "actionA"] });
171-
172-
await addCommand(ctx);
155+
await add.run(testCtx, makeArgs(add.args, "add", "action", "actionA"));
173156
});
174157

175158
it("requires an action name/path", async () => {
176-
const ctx = makeContext({ parse: args, argv: ["add", "action"] });
177-
178-
const error = await expectError(() => addCommand(ctx));
159+
const error = await expectError(() => add.run(testCtx, makeArgs(add.args, "add", "action")));
179160
expect(error).toBeInstanceOf(ArgError);
180161
expect(error.sprint()).toMatchInlineSnapshot(`
181162
"✘ Failed to add action, missing action path
@@ -195,15 +176,11 @@ describe("add", () => {
195176
expectVariables: { method: "GET", path: "routeA" },
196177
});
197178

198-
const ctx = makeContext({ parse: args, argv: ["add", "route", "GET", "routeA"] });
199-
200-
await addCommand(ctx);
179+
await add.run(testCtx, makeArgs(add.args, "add", "route", "GET", "routeA"));
201180
});
202181

203182
it("requires a method argument", async () => {
204-
const ctx = makeContext({ parse: args, argv: ["add", "route"] });
205-
206-
const error = await expectError(() => addCommand(ctx));
183+
const error = await expectError(() => add.run(testCtx, makeArgs(add.args, "add", "route")));
207184
expect(error).toBeInstanceOf(ArgError);
208185
expect(error.sprint()).toMatchInlineSnapshot(`
209186
"✘ Failed to add route, missing route method
@@ -214,9 +191,7 @@ describe("add", () => {
214191
});
215192

216193
it("requires a route name", async () => {
217-
const ctx = makeContext({ parse: args, argv: ["add", "route", "GET"] });
218-
219-
const error = await expectError(() => addCommand(ctx));
194+
const error = await expectError(() => add.run(testCtx, makeArgs(add.args, "add", "route", "GET")));
220195
expect(error).toBeInstanceOf(ArgError);
221196
expect(error.sprint()).toMatchInlineSnapshot(`
222197
"✘ Failed to add route, missing route path

0 commit comments

Comments
 (0)