@seed-design/cli 1.3.1 → 1.3.3

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.
@@ -1,4 +1,4 @@
1
- import { fetchDocsIndex } from "@/src/utils/fetch";
1
+ import { fetchDocsIndex, fetchLlmsTxt, tryFetchLlmsTxt } from "@/src/utils/fetch";
2
2
  import * as p from "@clack/prompts";
3
3
  import type { CAC } from "cac";
4
4
  import { z } from "zod";
@@ -20,6 +20,7 @@ const GITHUB_SNIPPET_BASE =
20
20
  const docsOptionsSchema = z.object({
21
21
  query: z.string().optional(),
22
22
  baseUrl: z.string().optional(),
23
+ raw: z.boolean(),
23
24
  });
24
25
 
25
26
  function buildSnippetUrl(registryId: string, snippetPath: string): string {
@@ -232,15 +233,22 @@ export const docsCommand = (cli: CAC) => {
232
233
  .option("-u, --baseUrl <baseUrl>", `레지스트리의 기본 URL (기본값: ${BASE_URL})`, {
233
234
  default: BASE_URL,
234
235
  })
236
+ .option("--raw", "llms.txt 내용을 직접 가져와 출력합니다. LLM 파이프에 유용합니다.", {
237
+ default: false,
238
+ })
235
239
  .example("seed-design docs")
236
240
  .example("seed-design docs action-button")
237
241
  .example("seed-design docs react")
238
242
  .example("seed-design docs react/components")
239
243
  .example("seed-design docs react/components/action-button")
244
+ .example("seed-design docs react/updates/changelog --raw")
240
245
  .action(async (query, opts) => {
241
246
  const startTime = Date.now();
242
247
  const verbose = isVerboseMode(opts);
243
- p.intro("seed-design docs");
248
+ const raw = opts.raw ?? false;
249
+ const trackCwd = process.cwd();
250
+
251
+ if (!raw) p.intro("seed-design docs");
244
252
 
245
253
  try {
246
254
  const parsed = docsOptionsSchema.safeParse({ query, ...opts });
@@ -251,10 +259,19 @@ export const docsCommand = (cli: CAC) => {
251
259
  const { data: options } = parsed;
252
260
  const baseUrl = options.baseUrl ?? BASE_URL;
253
261
 
254
- const { start, stop } = p.spinner();
255
- start("문서 목록을 가져오고 있어요...");
262
+ if (options.raw && !options.query) {
263
+ throw new CliError({
264
+ message: "--raw 모드에서는 쿼리가 필요해요.",
265
+ hint: "예: `seed-design docs react/updates/changelog --raw`",
266
+ });
267
+ }
256
268
 
257
269
  const docsIndex = await (async () => {
270
+ if (raw) {
271
+ return await fetchDocsIndex({ baseUrl });
272
+ }
273
+ const { start, stop } = p.spinner();
274
+ start("문서 목록을 가져오고 있어요...");
258
275
  try {
259
276
  const index = await fetchDocsIndex({ baseUrl });
260
277
  stop("문서 목록을 가져왔어요.");
@@ -266,23 +283,31 @@ export const docsCommand = (cli: CAC) => {
266
283
  })();
267
284
 
268
285
  const { categories } = docsIndex;
269
- let selectedItem: DocsItem;
286
+ let selectedItem: DocsItem | undefined;
287
+
288
+ // In --raw mode, wrap index resolution in try-catch to allow fallback to direct URL
289
+ const resolveFromIndex = async (): Promise<DocsItem | undefined> => {
290
+ if (options.query) {
291
+ const segments = parseQueryPath(options.query);
270
292
 
271
- if (options.query) {
272
- const segments = parseQueryPath(options.query);
293
+ // Deep paths (more than category/section/item) can't be resolved from index
294
+ // e.g., react/updates/changelog/react/1.2.9 — skip to fallback in --raw mode
295
+ if (raw && segments.length > 3) {
296
+ return undefined;
297
+ }
273
298
 
274
- // Try to resolve as path: category / section / item
275
- const matchedCategory = categories.find((c) => c.id === segments[0]);
299
+ // Try to resolve as path: category / section / item
300
+ const matchedCategory = categories.find((c) => c.id === segments[0]);
276
301
 
277
- if (matchedCategory && segments.length >= 2) {
278
- const matchedSection = matchedCategory.sections.find((s) => s.id === segments[1]);
302
+ if (matchedCategory && segments.length >= 2) {
303
+ const matchedSection = matchedCategory.sections.find((s) => s.id === segments[1]);
279
304
 
280
- if (matchedSection && segments.length >= 3) {
281
- // Full path: category/section/item
282
- const matchedItem = matchedSection.items.find((i) => i.id === segments[2]);
283
- if (matchedItem) {
284
- selectedItem = matchedItem;
285
- } else {
305
+ if (matchedSection && segments.length >= 3) {
306
+ // Full path: category/section/item
307
+ const matchedItem = matchedSection.items.find((i) => i.id === segments[2]);
308
+ if (matchedItem) {
309
+ return matchedItem;
310
+ }
286
311
  // Item not found in section — search within the section
287
312
  const q = segments[2].toLowerCase();
288
313
  const matched = matchedSection.items.filter(
@@ -306,15 +331,14 @@ export const docsCommand = (cli: CAC) => {
306
331
  });
307
332
  }
308
333
  if (matched.length === 1) {
309
- selectedItem = matched[0];
310
- } else {
311
- selectedItem = await selectItem(matched);
334
+ return matched[0];
312
335
  }
336
+ return await selectItem(matched);
337
+ }
338
+ if (matchedSection) {
339
+ // category/section — select item within section
340
+ return await selectItem(matchedSection.items);
313
341
  }
314
- } else if (matchedSection) {
315
- // category/section — select item within section
316
- selectedItem = await selectItem(matchedSection.items);
317
- } else {
318
342
  // category/??? — search within category
319
343
  const q = segments[1].toLowerCase();
320
344
  const matched = matchedCategory.sections.flatMap((s) =>
@@ -329,7 +353,10 @@ export const docsCommand = (cli: CAC) => {
329
353
  const sectionIds = matchedCategory.sections.map((s) => s.id);
330
354
  const similarSections = findSimilar(segments[1], sectionIds);
331
355
  const allItemIds = matchedCategory.sections.flatMap((s) =>
332
- s.items.map((i) => ({ path: `${matchedCategory.id}/${s.id}/${i.id}`, id: i.id })),
356
+ s.items.map((i) => ({
357
+ path: `${matchedCategory.id}/${s.id}/${i.id}`,
358
+ id: i.id,
359
+ })),
333
360
  );
334
361
  const similarItems = findSimilar(
335
362
  segments[1],
@@ -352,29 +379,27 @@ export const docsCommand = (cli: CAC) => {
352
379
  });
353
380
  }
354
381
  if (matched.length === 1) {
355
- selectedItem = matched[0].item;
356
- } else {
357
- const selected = await p.select({
358
- message: `${highlight(segments[1])}에 해당하는 항목을 선택해주세요`,
359
- options: matched.map(({ item, sectionLabel }) => ({
360
- label: `[${sectionLabel}] ${item.title}`,
361
- value: item,
362
- hint: item.description,
363
- })),
364
- });
365
- if (p.isCancel(selected)) throw new CliCancelError();
366
- selectedItem = selected;
382
+ return matched[0].item;
367
383
  }
384
+ const selected = await p.select({
385
+ message: `${highlight(segments[1])}에 해당하는 항목을 선택해주세요`,
386
+ options: matched.map(({ item, sectionLabel }) => ({
387
+ label: `[${sectionLabel}] ${item.title}`,
388
+ value: item,
389
+ hint: item.description,
390
+ })),
391
+ });
392
+ if (p.isCancel(selected)) throw new CliCancelError();
393
+ return selected;
368
394
  }
369
- } else if (matchedCategory) {
370
- // Single segment matching a category — drill into it
371
- if (matchedCategory.sections.length === 1) {
372
- selectedItem = await selectItem(matchedCategory.sections[0].items);
373
- } else {
395
+ if (matchedCategory) {
396
+ // Single segment matching a category — drill into it
397
+ if (matchedCategory.sections.length === 1) {
398
+ return await selectItem(matchedCategory.sections[0].items);
399
+ }
374
400
  const section = await selectSection(matchedCategory.sections);
375
- selectedItem = await selectItem(section.items);
401
+ return await selectItem(section.items);
376
402
  }
377
- } else {
378
403
  // No category match — global search
379
404
  const matched = searchAllItems(categories, options.query);
380
405
 
@@ -386,22 +411,21 @@ export const docsCommand = (cli: CAC) => {
386
411
  });
387
412
  }
388
413
  if (matched.length === 1) {
389
- selectedItem = matched[0].item;
390
- } else {
391
- const selected = await p.select({
392
- message: `${highlight(options.query)}에 해당하는 항목을 선택해주세요`,
393
- options: matched.map(({ item, categoryLabel, sectionLabel }) => ({
394
- label: `[${categoryLabel} > ${sectionLabel}] ${item.title}`,
395
- value: item,
396
- hint: item.description,
397
- })),
398
- });
399
- if (p.isCancel(selected)) throw new CliCancelError();
400
- selectedItem = selected;
414
+ return matched[0].item;
401
415
  }
416
+ const selected = await p.select({
417
+ message: `${highlight(options.query)}에 해당하는 항목을 선택해주세요`,
418
+ options: matched.map(({ item, categoryLabel, sectionLabel }) => ({
419
+ label: `[${categoryLabel} > ${sectionLabel}] ${item.title}`,
420
+ value: item,
421
+ hint: item.description,
422
+ })),
423
+ });
424
+ if (p.isCancel(selected)) throw new CliCancelError();
425
+ return selected;
402
426
  }
403
- } else {
404
- // Full interactive flow: category → section → item
427
+
428
+ // No query — full interactive flow: category → section → item
405
429
  const category = await selectCategory(categories);
406
430
 
407
431
  let section: DocsSection;
@@ -411,34 +435,94 @@ export const docsCommand = (cli: CAC) => {
411
435
  section = await selectSection(category.sections);
412
436
  }
413
437
 
414
- selectedItem = await selectItem(section.items);
438
+ return await selectItem(section.items);
439
+ };
440
+
441
+ // In --raw mode, swallow index resolution errors and fall back to direct URL fetch
442
+ if (raw) {
443
+ try {
444
+ selectedItem = await resolveFromIndex();
445
+ } catch (error) {
446
+ if (isCliCancelError(error)) throw error;
447
+ // index miss in raw mode → will use fallback
448
+ }
449
+ } else {
450
+ selectedItem = await resolveFromIndex();
415
451
  }
416
452
 
417
- printDocsResult(selectedItem, baseUrl);
418
- p.outro("완료했어요.");
453
+ if (raw) {
454
+ let content: string;
455
+ if (selectedItem) {
456
+ const llmsUrl = `${baseUrl}/llms${selectedItem.docUrl}.txt`;
457
+ content = await fetchLlmsTxt({ url: llmsUrl });
458
+ } else {
459
+ content = await tryFetchLlmsTxt({ baseUrl, query: options.query! });
460
+ }
461
+ console.log(content);
462
+ } else {
463
+ printDocsResult(selectedItem!, baseUrl);
464
+ p.outro("완료했어요.");
465
+ }
419
466
 
420
467
  const duration = Date.now() - startTime;
421
468
  try {
422
- await analytics.track(process.cwd(), {
423
- event: "docs",
469
+ await analytics.trackCommandOutcome(trackCwd, {
470
+ command: "docs",
471
+ status: "completed",
424
472
  properties: {
425
473
  query: options.query ?? null,
426
- item_id: selectedItem.id,
427
- has_snippet: !!(selectedItem.snippets && selectedItem.snippets.length > 0),
474
+ item_id: selectedItem?.id ?? options.query ?? null,
475
+ has_snippet: !!(selectedItem?.snippets && selectedItem.snippets.length > 0),
476
+ raw_mode: raw,
428
477
  duration_ms: duration,
429
478
  },
430
479
  });
431
480
  } catch (telemetryError) {
432
481
  if (verbose) {
433
- console.error("[Telemetry] docs tracking failed:", telemetryError);
482
+ console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
434
483
  }
435
484
  }
436
485
  } catch (error) {
437
486
  if (isCliCancelError(error)) {
438
- p.outro(highlight(error.message));
487
+ try {
488
+ await analytics.trackCommandOutcome(trackCwd, {
489
+ command: "docs",
490
+ status: "cancelled",
491
+ properties: {
492
+ raw_mode: raw,
493
+ duration_ms: Date.now() - startTime,
494
+ },
495
+ });
496
+ } catch (telemetryError) {
497
+ if (verbose) {
498
+ console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
499
+ }
500
+ }
501
+ if (!raw) p.outro(highlight(error.message));
439
502
  process.exit(0);
440
503
  }
441
504
 
505
+ try {
506
+ await analytics.trackCommandFailure(trackCwd, {
507
+ command: "docs",
508
+ error,
509
+ properties: {
510
+ raw_mode: raw,
511
+ duration_ms: Date.now() - startTime,
512
+ },
513
+ });
514
+ } catch (telemetryError) {
515
+ if (verbose) {
516
+ console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
517
+ }
518
+ }
519
+
520
+ if (raw) {
521
+ const msg = error instanceof Error ? error.message : String(error);
522
+ console.error(msg);
523
+ process.exit(1);
524
+ }
525
+
442
526
  handleCliError(error, {
443
527
  defaultMessage: "문서 조회에 실패했어요.",
444
528
  defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
@@ -27,6 +27,7 @@ export const initCommand = (cli: CAC) => {
27
27
  .action(async (opts) => {
28
28
  const startTime = Date.now();
29
29
  const verbose = isVerboseMode(opts);
30
+ const trackCwd = typeof opts?.cwd === "string" ? opts.cwd : process.cwd();
30
31
  p.intro("seed-design.json 파일 생성");
31
32
 
32
33
  try {
@@ -78,8 +79,9 @@ export const initCommand = (cli: CAC) => {
78
79
  // init 성공 이벤트 추적
79
80
  const duration = Date.now() - startTime;
80
81
  try {
81
- await analytics.track(options.cwd, {
82
- event: "init",
82
+ await analytics.trackCommandOutcome(options.cwd, {
83
+ command: "init",
84
+ status: "completed",
83
85
  properties: {
84
86
  tsx: config.tsx,
85
87
  rsc: config.rsc,
@@ -90,15 +92,42 @@ export const initCommand = (cli: CAC) => {
90
92
  });
91
93
  } catch (telemetryError) {
92
94
  if (verbose) {
93
- console.error("[Telemetry] init tracking failed:", telemetryError);
95
+ console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
94
96
  }
95
97
  }
96
98
  } catch (error) {
97
99
  if (isCliCancelError(error)) {
100
+ try {
101
+ await analytics.trackCommandOutcome(trackCwd, {
102
+ command: "init",
103
+ status: "cancelled",
104
+ properties: {
105
+ duration_ms: Date.now() - startTime,
106
+ },
107
+ });
108
+ } catch (telemetryError) {
109
+ if (verbose) {
110
+ console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
111
+ }
112
+ }
98
113
  p.outro(highlight(error.message));
99
114
  process.exit(0);
100
115
  }
101
116
 
117
+ try {
118
+ await analytics.trackCommandFailure(trackCwd, {
119
+ command: "init",
120
+ error,
121
+ properties: {
122
+ duration_ms: Date.now() - startTime,
123
+ },
124
+ });
125
+ } catch (telemetryError) {
126
+ if (verbose) {
127
+ console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
128
+ }
129
+ }
130
+
102
131
  handleCliError(error, {
103
132
  defaultMessage: "seed-design.json 파일 생성에 실패했어요.",
104
133
  defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import { addAllCommand } from "@/src/commands/add-all";
5
5
  import { compatCommand } from "@/src/commands/compat";
6
6
  import { docsCommand } from "@/src/commands/docs";
7
7
  import { initCommand } from "@/src/commands/init";
8
- import { upgradeCommand } from "@/src/commands/upgrade";
8
+
9
9
  import { getPackageInfo } from "@/src/utils/get-package-info";
10
10
  import { cac } from "cac";
11
11
 
@@ -23,7 +23,6 @@ async function main() {
23
23
  compatCommand(CLI);
24
24
  docsCommand(CLI);
25
25
  initCommand(CLI);
26
- upgradeCommand(CLI);
27
26
 
28
27
  CLI.version(packageInfo.version || "1.0.0", "-v, --version");
29
28
  CLI.help();
@@ -0,0 +1,95 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
2
+ import fs from "fs-extra";
3
+ import os from "os";
4
+ import path from "path";
5
+ import * as prompts from "@clack/prompts";
6
+ import { analytics } from "../utils/analytics";
7
+
8
+ describe("analytics command outcome tracking", () => {
9
+ const originalEnv = { ...process.env };
10
+ const tempDirs: string[] = [];
11
+
12
+ beforeEach(() => {
13
+ process.env.NODE_ENV = "prod";
14
+ process.env.POSTHOG_API_KEY = "test-api-key";
15
+ process.env.POSTHOG_HOST = "https://us.i.posthog.com";
16
+ delete process.env.DISABLE_TELEMETRY;
17
+ delete process.env.SEED_DISABLE_TELEMETRY;
18
+ });
19
+
20
+ afterEach(async () => {
21
+ process.env = { ...originalEnv };
22
+ mock.restore();
23
+
24
+ while (tempDirs.length > 0) {
25
+ const dir = tempDirs.pop();
26
+ if (dir) {
27
+ await fs.remove(dir);
28
+ }
29
+ }
30
+ });
31
+
32
+ async function createTempCwd() {
33
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "seed-cli-analytics-"));
34
+ tempDirs.push(cwd);
35
+ return cwd;
36
+ }
37
+
38
+ it("command outcome payload에 status를 포함해야 한다", async () => {
39
+ const cwd = await createTempCwd();
40
+ const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(
41
+ async () => new Response(null, { status: 200 }),
42
+ );
43
+ const infoSpy = spyOn(prompts.log, "info").mockImplementation(() => {});
44
+
45
+ await analytics.trackCommandOutcome(cwd, {
46
+ command: "add",
47
+ status: "completed",
48
+ properties: {
49
+ items_count: 2,
50
+ },
51
+ });
52
+
53
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
54
+ expect(infoSpy).toHaveBeenCalled();
55
+
56
+ const [, request] = fetchSpy.mock.calls[0] ?? [];
57
+ const payload = JSON.parse(String(request?.body));
58
+
59
+ expect(payload.event).toBe("seed_cli.add");
60
+ expect(payload.properties).toMatchObject({
61
+ status: "completed",
62
+ items_count: 2,
63
+ $process_person_profile: false,
64
+ });
65
+ });
66
+
67
+ it("failed outcome payload에는 error_type만 포함하고 message는 포함하지 않아야 한다", async () => {
68
+ const cwd = await createTempCwd();
69
+ const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(
70
+ async () => new Response(null, { status: 200 }),
71
+ );
72
+ spyOn(prompts.log, "info").mockImplementation(() => {});
73
+
74
+ await analytics.trackCommandFailure(cwd, {
75
+ command: "docs",
76
+ error: new Error("sensitive details"),
77
+ properties: {
78
+ raw_mode: true,
79
+ },
80
+ });
81
+
82
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
83
+
84
+ const [, request] = fetchSpy.mock.calls[0] ?? [];
85
+ const payload = JSON.parse(String(request?.body));
86
+
87
+ expect(payload.event).toBe("seed_cli.docs");
88
+ expect(payload.properties).toMatchObject({
89
+ status: "failed",
90
+ error_type: "Error",
91
+ raw_mode: true,
92
+ });
93
+ expect(payload.properties.error_message).toBeUndefined();
94
+ });
95
+ });
@@ -0,0 +1,185 @@
1
+ import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
2
+ import type { CAC } from "cac";
3
+ import { cac } from "cac";
4
+ import { CliCancelError } from "../utils/error";
5
+ import { analytics } from "../utils/analytics";
6
+
7
+ const introMock = mock(() => {});
8
+ const outroMock = mock(() => {});
9
+ const infoMock = mock(() => {});
10
+ const messageMock = mock(() => {});
11
+ const errorMock = mock(() => {});
12
+ const noteMock = mock(() => {});
13
+ const spinnerStartMock = mock(() => {});
14
+ const spinnerStopMock = mock(() => {});
15
+ const promptInitConfigMock = mock(async () => ({
16
+ tsx: true,
17
+ rsc: false,
18
+ path: "./seed-design",
19
+ telemetry: true,
20
+ }));
21
+ const writeInitConfigFileMock = mock(async () => ({
22
+ relativePath: "seed-design.json",
23
+ }));
24
+
25
+ mock.module("@clack/prompts", () => ({
26
+ intro: introMock,
27
+ outro: outroMock,
28
+ note: noteMock,
29
+ log: {
30
+ info: infoMock,
31
+ message: messageMock,
32
+ error: errorMock,
33
+ },
34
+ spinner: () => ({
35
+ start: spinnerStartMock,
36
+ stop: spinnerStopMock,
37
+ }),
38
+ isCancel: () => false,
39
+ }));
40
+
41
+ mock.module("../utils/init-config", () => ({
42
+ DEFAULT_INIT_CONFIG: {
43
+ tsx: true,
44
+ rsc: false,
45
+ path: "./seed-design",
46
+ telemetry: true,
47
+ },
48
+ promptInitConfig: promptInitConfigMock,
49
+ writeInitConfigFile: writeInitConfigFileMock,
50
+ }));
51
+
52
+ const { addCommand } = await import("../commands/add");
53
+ const { initCommand } = await import("../commands/init");
54
+
55
+ function getCommand(cli: CAC, name: string) {
56
+ const command = cli.commands.find((item) => item.name === name);
57
+
58
+ if (!command) {
59
+ throw new Error(`Command not found: ${name}`);
60
+ }
61
+
62
+ return command;
63
+ }
64
+
65
+ describe("command telemetry", () => {
66
+ beforeEach(() => {
67
+ introMock.mockClear();
68
+ outroMock.mockClear();
69
+ infoMock.mockClear();
70
+ messageMock.mockClear();
71
+ errorMock.mockClear();
72
+ noteMock.mockClear();
73
+ spinnerStartMock.mockClear();
74
+ spinnerStopMock.mockClear();
75
+ promptInitConfigMock.mockClear();
76
+ writeInitConfigFileMock.mockClear();
77
+ });
78
+
79
+ it("init 성공 시 completed outcome을 전송해야 한다", async () => {
80
+ const trackCommandOutcomeSpy = spyOn(analytics, "trackCommandOutcome").mockImplementation(
81
+ async () => {},
82
+ );
83
+ const trackCommandFailureSpy = spyOn(analytics, "trackCommandFailure").mockImplementation(
84
+ async () => {},
85
+ );
86
+ const cli = cac("seed-design");
87
+ initCommand(cli);
88
+
89
+ await getCommand(cli, "init").commandAction({
90
+ cwd: "/tmp/seed-design",
91
+ yes: true,
92
+ default: false,
93
+ });
94
+
95
+ expect(writeInitConfigFileMock).toHaveBeenCalledTimes(1);
96
+ expect(trackCommandOutcomeSpy).toHaveBeenCalledWith(
97
+ "/tmp/seed-design",
98
+ expect.objectContaining({
99
+ command: "init",
100
+ status: "completed",
101
+ properties: expect.objectContaining({
102
+ yes_option: true,
103
+ telemetry: true,
104
+ }),
105
+ }),
106
+ );
107
+ expect(trackCommandFailureSpy).not.toHaveBeenCalled();
108
+
109
+ trackCommandOutcomeSpy.mockRestore();
110
+ trackCommandFailureSpy.mockRestore();
111
+ });
112
+
113
+ it("init 취소 시 cancelled outcome을 전송해야 한다", async () => {
114
+ const trackCommandOutcomeSpy = spyOn(analytics, "trackCommandOutcome").mockImplementation(
115
+ async () => {},
116
+ );
117
+ const trackCommandFailureSpy = spyOn(analytics, "trackCommandFailure").mockImplementation(
118
+ async () => {},
119
+ );
120
+ promptInitConfigMock.mockImplementationOnce(async () => {
121
+ throw new CliCancelError("작업이 취소됐어요.");
122
+ });
123
+
124
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
125
+ throw new Error(`EXIT:${code}`);
126
+ }) as never);
127
+
128
+ const cli = cac("seed-design");
129
+ initCommand(cli);
130
+
131
+ await expect(
132
+ getCommand(cli, "init").commandAction({
133
+ cwd: "/tmp/seed-design",
134
+ yes: false,
135
+ default: false,
136
+ }),
137
+ ).rejects.toThrow("EXIT:0");
138
+
139
+ expect(trackCommandOutcomeSpy).toHaveBeenCalledWith(
140
+ "/tmp/seed-design",
141
+ expect.objectContaining({
142
+ command: "init",
143
+ status: "cancelled",
144
+ }),
145
+ );
146
+ expect(trackCommandFailureSpy).not.toHaveBeenCalled();
147
+
148
+ exitSpy.mockRestore();
149
+ trackCommandOutcomeSpy.mockRestore();
150
+ trackCommandFailureSpy.mockRestore();
151
+ });
152
+
153
+ it("add 실패 시 failed outcome을 전송해야 한다", async () => {
154
+ const trackCommandFailureSpy = spyOn(analytics, "trackCommandFailure").mockImplementation(
155
+ async () => {},
156
+ );
157
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
158
+ throw new Error(`EXIT:${code}`);
159
+ }) as never);
160
+
161
+ const cli = cac("seed-design");
162
+ addCommand(cli);
163
+
164
+ await expect(
165
+ getCommand(cli, "add").commandAction([], {
166
+ all: true,
167
+ cwd: "/tmp/seed-design",
168
+ baseUrl: "https://seed-design.io",
169
+ }),
170
+ ).rejects.toThrow("EXIT:1");
171
+
172
+ expect(trackCommandFailureSpy).toHaveBeenCalledWith(
173
+ "/tmp/seed-design",
174
+ expect.objectContaining({
175
+ command: "add",
176
+ error: expect.objectContaining({
177
+ name: "CliError",
178
+ }),
179
+ }),
180
+ );
181
+
182
+ exitSpy.mockRestore();
183
+ trackCommandFailureSpy.mockRestore();
184
+ });
185
+ });