@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.
- package/bin/index.mjs +21 -21
- package/package.json +1 -1
- package/src/commands/add-all.ts +32 -3
- package/src/commands/add.ts +32 -3
- package/src/commands/compat.ts +52 -6
- package/src/commands/docs.ts +151 -67
- package/src/commands/init.ts +32 -3
- package/src/index.ts +1 -2
- package/src/tests/analytics.test.ts +95 -0
- package/src/tests/command-telemetry.test.ts +185 -0
- package/src/utils/analytics.ts +65 -1
- package/src/utils/fetch.ts +53 -21
- package/src/commands/upgrade.ts +0 -387
package/src/commands/docs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
299
|
+
// Try to resolve as path: category / section / item
|
|
300
|
+
const matchedCategory = categories.find((c) => c.id === segments[0]);
|
|
276
301
|
|
|
277
|
-
|
|
278
|
-
|
|
302
|
+
if (matchedCategory && segments.length >= 2) {
|
|
303
|
+
const matchedSection = matchedCategory.sections.find((s) => s.id === segments[1]);
|
|
279
304
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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) => ({
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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.
|
|
423
|
-
|
|
469
|
+
await analytics.trackCommandOutcome(trackCwd, {
|
|
470
|
+
command: "docs",
|
|
471
|
+
status: "completed",
|
|
424
472
|
properties: {
|
|
425
473
|
query: options.query ?? null,
|
|
426
|
-
item_id: selectedItem.
|
|
427
|
-
has_snippet: !!(selectedItem
|
|
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
|
|
482
|
+
console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
|
|
434
483
|
}
|
|
435
484
|
}
|
|
436
485
|
} catch (error) {
|
|
437
486
|
if (isCliCancelError(error)) {
|
|
438
|
-
|
|
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` 옵션으로 상세 오류를 확인해보세요.",
|
package/src/commands/init.ts
CHANGED
|
@@ -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.
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|