@blotch/dolan 0.0.0-stage → 22.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/add.d.ts +22 -0
- package/dist/commands/add.js +132 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/add.test.d.ts +1 -0
- package/dist/commands/add.test.js +209 -0
- package/dist/commands/add.test.js.map +1 -0
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +155 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/diff.test.d.ts +1 -0
- package/dist/commands/diff.test.js +327 -0
- package/dist/commands/diff.test.js.map +1 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.js +248 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/doctor.test.d.ts +1 -0
- package/dist/commands/doctor.test.js +301 -0
- package/dist/commands/doctor.test.js.map +1 -0
- package/dist/commands/eject.d.ts +11 -0
- package/dist/commands/eject.js +55 -0
- package/dist/commands/eject.js.map +1 -0
- package/dist/commands/eject.test.d.ts +1 -0
- package/dist/commands/eject.test.js +151 -0
- package/dist/commands/eject.test.js.map +1 -0
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +73 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/info.test.d.ts +1 -0
- package/dist/commands/info.test.js +143 -0
- package/dist/commands/info.test.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +98 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +1 -0
- package/dist/commands/init.test.js +154 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/commands/list.d.ts +16 -0
- package/dist/commands/list.js +124 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/list.test.d.ts +1 -0
- package/dist/commands/list.test.js +285 -0
- package/dist/commands/list.test.js.map +1 -0
- package/dist/commands/outdated.d.ts +13 -0
- package/dist/commands/outdated.js +116 -0
- package/dist/commands/outdated.js.map +1 -0
- package/dist/commands/outdated.test.d.ts +1 -0
- package/dist/commands/outdated.test.js +208 -0
- package/dist/commands/outdated.test.js.map +1 -0
- package/dist/commands/remove.d.ts +18 -0
- package/dist/commands/remove.js +161 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/remove.test.d.ts +1 -0
- package/dist/commands/remove.test.js +361 -0
- package/dist/commands/remove.test.js.map +1 -0
- package/dist/commands/theme.d.ts +1 -0
- package/dist/commands/theme.js +66 -0
- package/dist/commands/theme.js.map +1 -0
- package/dist/commands/theme.test.d.ts +1 -0
- package/dist/commands/theme.test.js +133 -0
- package/dist/commands/theme.test.js.map +1 -0
- package/dist/commands/update.d.ts +31 -0
- package/dist/commands/update.js +601 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/update.test.d.ts +1 -0
- package/dist/commands/update.test.js +1233 -0
- package/dist/commands/update.test.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +48 -0
- package/dist/config.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/lockfile.d.ts +21 -0
- package/dist/lockfile.js +39 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +85 -0
- package/dist/registry.js.map +1 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +95 -0
- package/dist/registry.test.js.map +1 -0
- package/dist/test-utils.d.ts +16 -0
- package/dist/test-utils.js +65 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/ui/components/accordion/accordion.test.ts +194 -0
- package/dist/ui/components/accordion/accordion.ts +173 -0
- package/dist/ui/components/accordion/index.ts +6 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
- package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
- package/dist/ui/components/action-sheet/index.ts +6 -0
- package/dist/ui/components/alert/alert.test.ts +48 -0
- package/dist/ui/components/alert/alert.ts +104 -0
- package/dist/ui/components/alert/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
- package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
- package/dist/ui/components/alert-dialog/index.ts +7 -0
- package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
- package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
- package/dist/ui/components/area-chart/area-chart.ts +349 -0
- package/dist/ui/components/area-chart/index.ts +6 -0
- package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
- package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
- package/dist/ui/components/aspect-ratio/index.ts +1 -0
- package/dist/ui/components/avatar/avatar.test.ts +69 -0
- package/dist/ui/components/avatar/avatar.ts +131 -0
- package/dist/ui/components/avatar/index.ts +1 -0
- package/dist/ui/components/badge/badge.test.ts +49 -0
- package/dist/ui/components/badge/badge.ts +97 -0
- package/dist/ui/components/badge/index.ts +1 -0
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
- package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
- package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
- package/dist/ui/components/bar-chart/index.ts +6 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
- package/dist/ui/components/bottom-sheet/index.ts +1 -0
- package/dist/ui/components/button/button-render.test.ts +113 -0
- package/dist/ui/components/button/button.test.ts +70 -0
- package/dist/ui/components/button/button.ts +170 -0
- package/dist/ui/components/button/index.ts +6 -0
- package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
- package/dist/ui/components/button-group/button-group.test.ts +54 -0
- package/dist/ui/components/button-group/button-group.ts +209 -0
- package/dist/ui/components/button-group/index.ts +8 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
- package/dist/ui/components/candlestick-chart/index.ts +7 -0
- package/dist/ui/components/card/card.test.ts +110 -0
- package/dist/ui/components/card/card.ts +162 -0
- package/dist/ui/components/card/index.ts +8 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
- package/dist/ui/components/cartesian-chart/index.ts +18 -0
- package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
- package/dist/ui/components/checkbox/checkbox.ts +96 -0
- package/dist/ui/components/checkbox/index.ts +1 -0
- package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
- package/dist/ui/components/collapsible/collapsible.ts +122 -0
- package/dist/ui/components/collapsible/index.ts +5 -0
- package/dist/ui/components/dialog/dialog.test.ts +157 -0
- package/dist/ui/components/dialog/dialog.ts +248 -0
- package/dist/ui/components/dialog/index.ts +7 -0
- package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
- package/dist/ui/components/empty-state/empty-state.ts +46 -0
- package/dist/ui/components/empty-state/index.ts +1 -0
- package/dist/ui/components/icon/icon.test.ts +52 -0
- package/dist/ui/components/icon/icon.ts +54 -0
- package/dist/ui/components/icon/icons.ts +569 -0
- package/dist/ui/components/icon/index.ts +2 -0
- package/dist/ui/components/input/index.ts +1 -0
- package/dist/ui/components/input/input.test.ts +134 -0
- package/dist/ui/components/input/input.ts +189 -0
- package/dist/ui/components/label/index.ts +1 -0
- package/dist/ui/components/label/label.test.ts +38 -0
- package/dist/ui/components/label/label.ts +24 -0
- package/dist/ui/components/line-chart/index.ts +6 -0
- package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
- package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
- package/dist/ui/components/line-chart/line-chart.ts +273 -0
- package/dist/ui/components/list/index.ts +1 -0
- package/dist/ui/components/list/list.test.ts +35 -0
- package/dist/ui/components/list/list.ts +71 -0
- package/dist/ui/components/nav-drawer/index.ts +8 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
- package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
- package/dist/ui/components/pagination/index.ts +1 -0
- package/dist/ui/components/pagination/pagination.test.ts +96 -0
- package/dist/ui/components/pagination/pagination.ts +147 -0
- package/dist/ui/components/progress/index.ts +1 -0
- package/dist/ui/components/progress/progress.test.ts +71 -0
- package/dist/ui/components/progress/progress.ts +118 -0
- package/dist/ui/components/radio-group/index.ts +1 -0
- package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
- package/dist/ui/components/radio-group/radio-group.ts +129 -0
- package/dist/ui/components/scatter-chart/index.ts +6 -0
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
- package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
- package/dist/ui/components/scroll-area/index.ts +1 -0
- package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
- package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
- package/dist/ui/components/select/index.ts +1 -0
- package/dist/ui/components/select/select.test.ts +107 -0
- package/dist/ui/components/select/select.ts +146 -0
- package/dist/ui/components/separator/index.ts +1 -0
- package/dist/ui/components/separator/separator.test.ts +26 -0
- package/dist/ui/components/separator/separator.ts +45 -0
- package/dist/ui/components/sheet/index.ts +7 -0
- package/dist/ui/components/sheet/sheet.test.ts +44 -0
- package/dist/ui/components/sheet/sheet.ts +114 -0
- package/dist/ui/components/skeleton/index.ts +1 -0
- package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
- package/dist/ui/components/skeleton/skeleton.ts +45 -0
- package/dist/ui/components/spinner/index.ts +1 -0
- package/dist/ui/components/spinner/spinner.test.ts +39 -0
- package/dist/ui/components/spinner/spinner.ts +83 -0
- package/dist/ui/components/switch/index.ts +1 -0
- package/dist/ui/components/switch/switch.test.ts +63 -0
- package/dist/ui/components/switch/switch.ts +98 -0
- package/dist/ui/components/tabs/index.ts +1 -0
- package/dist/ui/components/tabs/tabs.test.ts +199 -0
- package/dist/ui/components/tabs/tabs.ts +317 -0
- package/dist/ui/components/textarea/index.ts +1 -0
- package/dist/ui/components/textarea/textarea.test.ts +138 -0
- package/dist/ui/components/textarea/textarea.ts +221 -0
- package/dist/ui/components/toast/index.ts +9 -0
- package/dist/ui/components/toast/toast-state.test.ts +54 -0
- package/dist/ui/components/toast/toast-state.ts +46 -0
- package/dist/ui/components/toast/toast.test.ts +342 -0
- package/dist/ui/components/toast/toast.ts +527 -0
- package/dist/ui/components/toggle/index.ts +7 -0
- package/dist/ui/components/toggle/toggle.test.ts +186 -0
- package/dist/ui/components/toggle/toggle.ts +218 -0
- package/dist/ui/components/typography/index.ts +5 -0
- package/dist/ui/components/typography/typography.test.ts +55 -0
- package/dist/ui/components/typography/typography.ts +44 -0
- package/dist/ui/theme/dark.css +41 -0
- package/dist/ui/theme/default.css +56 -0
- package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
- package/dist/ui/theme/tailwind-plugin.ts +151 -0
- package/dist/ui/utils/animate.test.ts +324 -0
- package/dist/ui/utils/animate.ts +544 -0
- package/dist/ui/utils/cn.test.ts +27 -0
- package/dist/ui/utils/cn.ts +6 -0
- package/dist/utils/analyze.d.ts +32 -0
- package/dist/utils/analyze.js +109 -0
- package/dist/utils/analyze.js.map +1 -0
- package/dist/utils/analyze.test.d.ts +1 -0
- package/dist/utils/analyze.test.js +146 -0
- package/dist/utils/analyze.test.js.map +1 -0
- package/dist/utils/animate.d.ts +210 -0
- package/dist/utils/animate.js +423 -0
- package/dist/utils/animate.js.map +1 -0
- package/dist/utils/animate.test.d.ts +1 -0
- package/dist/utils/animate.test.js +365 -0
- package/dist/utils/animate.test.js.map +1 -0
- package/dist/utils/banner.d.ts +1 -0
- package/dist/utils/banner.js +15 -0
- package/dist/utils/banner.js.map +1 -0
- package/dist/utils/banner.test.d.ts +1 -0
- package/dist/utils/banner.test.js +29 -0
- package/dist/utils/banner.test.js.map +1 -0
- package/dist/utils/cn.d.ts +2 -0
- package/dist/utils/cn.js +6 -0
- package/dist/utils/cn.js.map +1 -0
- package/dist/utils/cn.test.d.ts +1 -0
- package/dist/utils/cn.test.js +21 -0
- package/dist/utils/cn.test.js.map +1 -0
- package/dist/utils/format.d.ts +31 -0
- package/dist/utils/format.js +95 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/format.test.d.ts +1 -0
- package/dist/utils/format.test.js +71 -0
- package/dist/utils/format.test.js.map +1 -0
- package/dist/utils/logger.d.ts +6 -0
- package/dist/utils/logger.js +16 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/logger.test.d.ts +1 -0
- package/dist/utils/logger.test.js +36 -0
- package/dist/utils/logger.test.js.map +1 -0
- package/dist/utils/resolve-paths.d.ts +12 -0
- package/dist/utils/resolve-paths.js +29 -0
- package/dist/utils/resolve-paths.js.map +1 -0
- package/dist/utils/resolve-paths.test.d.ts +1 -0
- package/dist/utils/resolve-paths.test.js +52 -0
- package/dist/utils/resolve-paths.test.js.map +1 -0
- package/package.json +56 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createFixture, DEFAULT_CONFIG } from '../test-utils';
|
|
5
|
+
import { hashContent } from '../lockfile';
|
|
6
|
+
const spinnerMock = { start: vi.fn(), stop: vi.fn() };
|
|
7
|
+
vi.mock('@clack/prompts', () => ({
|
|
8
|
+
intro: vi.fn(),
|
|
9
|
+
outro: vi.fn(),
|
|
10
|
+
log: {
|
|
11
|
+
message: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
success: vi.fn(),
|
|
15
|
+
info: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
spinner: vi.fn(() => spinnerMock),
|
|
18
|
+
confirm: vi.fn().mockResolvedValue(true),
|
|
19
|
+
select: vi.fn().mockResolvedValue('upstream'),
|
|
20
|
+
cancel: vi.fn(),
|
|
21
|
+
isCancel: vi.fn().mockReturnValue(false),
|
|
22
|
+
}));
|
|
23
|
+
let fixture;
|
|
24
|
+
vi.mock('../utils/resolve-paths.js', () => ({
|
|
25
|
+
getUiSourceDir: () => fixture.uiDir,
|
|
26
|
+
getComponentSourceDir: (name) => join(fixture.uiDir, 'components', name),
|
|
27
|
+
getComponentFiles: (name) => readdirSync(join(fixture.uiDir, 'components', name)).filter((f) => f.endsWith('.ts')),
|
|
28
|
+
rewriteImports: (content) => content,
|
|
29
|
+
}));
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
32
|
+
throw new Error(`process.exit(${code})`);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
fixture?.cleanup();
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
const readLockfile = (dir) => JSON.parse(readFileSync(join(dir, 'dolan.lock.json'), 'utf-8'));
|
|
40
|
+
const readInstalledFile = (dir, component, file) => readFileSync(join(dir, DEFAULT_CONFIG.aliases.components, component, file), 'utf-8');
|
|
41
|
+
describe('updateCommand --selective', () => {
|
|
42
|
+
it('applies file when user chooses upstream', async () => {
|
|
43
|
+
const original = 'export const Card = { v: 1 };';
|
|
44
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
45
|
+
fixture = createFixture({
|
|
46
|
+
config: DEFAULT_CONFIG,
|
|
47
|
+
lockfile: {
|
|
48
|
+
version: 1,
|
|
49
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
50
|
+
utils: {},
|
|
51
|
+
theme: {},
|
|
52
|
+
},
|
|
53
|
+
components: { card: { 'card.ts': original } },
|
|
54
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
55
|
+
});
|
|
56
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
57
|
+
const p = await import('@clack/prompts');
|
|
58
|
+
p.select.mockResolvedValue('upstream');
|
|
59
|
+
const { updateCommand } = await import('./update');
|
|
60
|
+
await updateCommand({ selective: true });
|
|
61
|
+
const content = readInstalledFile(fixture.dir, 'card', 'card.ts');
|
|
62
|
+
expect(content).toBe(upstream);
|
|
63
|
+
const lockfile = readLockfile(fixture.dir);
|
|
64
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
65
|
+
});
|
|
66
|
+
it('skips file and preserves lockfile hash when user chooses keep', async () => {
|
|
67
|
+
const original = 'export const Card = { v: 1 };';
|
|
68
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
69
|
+
const originalHash = hashContent(original);
|
|
70
|
+
fixture = createFixture({
|
|
71
|
+
config: DEFAULT_CONFIG,
|
|
72
|
+
lockfile: {
|
|
73
|
+
version: 1,
|
|
74
|
+
components: { card: { 'card.ts': { hash: originalHash } } },
|
|
75
|
+
utils: {},
|
|
76
|
+
theme: {},
|
|
77
|
+
},
|
|
78
|
+
components: { card: { 'card.ts': original } },
|
|
79
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
80
|
+
});
|
|
81
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
82
|
+
const p = await import('@clack/prompts');
|
|
83
|
+
p.select.mockResolvedValue('keep');
|
|
84
|
+
const { updateCommand } = await import('./update');
|
|
85
|
+
await updateCommand({ selective: true });
|
|
86
|
+
const content = readInstalledFile(fixture.dir, 'card', 'card.ts');
|
|
87
|
+
expect(content).toBe(original);
|
|
88
|
+
const lockfile = readLockfile(fixture.dir);
|
|
89
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(originalHash);
|
|
90
|
+
});
|
|
91
|
+
it('handles mixed decisions across multiple files', async () => {
|
|
92
|
+
const cardOriginal = 'export const Card = { v: 1 };';
|
|
93
|
+
const cardUpstream = 'export const Card = { v: 2 };';
|
|
94
|
+
const spinnerOriginal = 'export const Spinner = { v: 1 };';
|
|
95
|
+
const spinnerUpstream = 'export const Spinner = { v: 2 };';
|
|
96
|
+
const cardHash = hashContent(cardOriginal);
|
|
97
|
+
const spinnerHash = hashContent(spinnerOriginal);
|
|
98
|
+
fixture = createFixture({
|
|
99
|
+
config: DEFAULT_CONFIG,
|
|
100
|
+
lockfile: {
|
|
101
|
+
version: 1,
|
|
102
|
+
components: {
|
|
103
|
+
card: { 'card.ts': { hash: cardHash } },
|
|
104
|
+
spinner: { 'spinner.ts': { hash: spinnerHash } },
|
|
105
|
+
},
|
|
106
|
+
utils: {},
|
|
107
|
+
theme: {},
|
|
108
|
+
},
|
|
109
|
+
components: {
|
|
110
|
+
card: { 'card.ts': cardOriginal },
|
|
111
|
+
spinner: { 'spinner.ts': spinnerOriginal },
|
|
112
|
+
},
|
|
113
|
+
uiSource: {
|
|
114
|
+
card: { 'card.ts': cardUpstream },
|
|
115
|
+
spinner: { 'spinner.ts': spinnerUpstream },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
119
|
+
const p = await import('@clack/prompts');
|
|
120
|
+
p.select
|
|
121
|
+
.mockResolvedValueOnce('upstream')
|
|
122
|
+
.mockResolvedValueOnce('keep');
|
|
123
|
+
const { updateCommand } = await import('./update');
|
|
124
|
+
await updateCommand({ selective: true });
|
|
125
|
+
// Card should be updated (first select → upstream)
|
|
126
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(cardUpstream);
|
|
127
|
+
// Spinner should be skipped (second select → keep)
|
|
128
|
+
expect(readInstalledFile(fixture.dir, 'spinner', 'spinner.ts')).toBe(spinnerOriginal);
|
|
129
|
+
const lockfile = readLockfile(fixture.dir);
|
|
130
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(cardUpstream));
|
|
131
|
+
expect(lockfile.components.spinner['spinner.ts'].hash).toBe(spinnerHash);
|
|
132
|
+
});
|
|
133
|
+
it('does not create new-upstream file when user chooses skip', async () => {
|
|
134
|
+
const upstream = 'export const Card = {};';
|
|
135
|
+
fixture = createFixture({
|
|
136
|
+
config: DEFAULT_CONFIG,
|
|
137
|
+
lockfile: {
|
|
138
|
+
version: 1,
|
|
139
|
+
components: {},
|
|
140
|
+
utils: {},
|
|
141
|
+
theme: {},
|
|
142
|
+
},
|
|
143
|
+
components: { card: {} },
|
|
144
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
145
|
+
});
|
|
146
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
147
|
+
const p = await import('@clack/prompts');
|
|
148
|
+
p.select.mockResolvedValue('keep');
|
|
149
|
+
const { updateCommand } = await import('./update');
|
|
150
|
+
await updateCommand({ selective: true });
|
|
151
|
+
const filePath = join(fixture.dir, DEFAULT_CONFIG.aliases.components, 'card', 'card.ts');
|
|
152
|
+
expect(existsSync(filePath)).toBe(false);
|
|
153
|
+
const lockfile = readLockfile(fixture.dir);
|
|
154
|
+
expect(lockfile.components.card?.['card.ts']).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
it('force overrides selective — no per-file prompts', async () => {
|
|
157
|
+
const original = 'export const Card = { v: 1 };';
|
|
158
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
159
|
+
fixture = createFixture({
|
|
160
|
+
config: DEFAULT_CONFIG,
|
|
161
|
+
lockfile: {
|
|
162
|
+
version: 1,
|
|
163
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
164
|
+
utils: {},
|
|
165
|
+
theme: {},
|
|
166
|
+
},
|
|
167
|
+
components: { card: { 'card.ts': original } },
|
|
168
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
169
|
+
});
|
|
170
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
171
|
+
const p = await import('@clack/prompts');
|
|
172
|
+
p.select.mockClear();
|
|
173
|
+
const { updateCommand } = await import('./update');
|
|
174
|
+
await updateCommand({ force: true, selective: true });
|
|
175
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
176
|
+
expect(p.select).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
it('conflict-keep still advances lockfile hash in selective mode', async () => {
|
|
179
|
+
const base = 'export const Card = { v: 1 };';
|
|
180
|
+
const userVersion = 'export const Card = { v: 1, custom: true };';
|
|
181
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
182
|
+
const baseHash = hashContent(base);
|
|
183
|
+
fixture = createFixture({
|
|
184
|
+
config: DEFAULT_CONFIG,
|
|
185
|
+
lockfile: {
|
|
186
|
+
version: 1,
|
|
187
|
+
components: { card: { 'card.ts': { hash: baseHash } } },
|
|
188
|
+
utils: {},
|
|
189
|
+
theme: {},
|
|
190
|
+
},
|
|
191
|
+
components: { card: { 'card.ts': userVersion } },
|
|
192
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
193
|
+
});
|
|
194
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
195
|
+
const p = await import('@clack/prompts');
|
|
196
|
+
p.select.mockResolvedValue('keep');
|
|
197
|
+
const { updateCommand } = await import('./update');
|
|
198
|
+
await updateCommand({ selective: true });
|
|
199
|
+
// File should be untouched
|
|
200
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(userVersion);
|
|
201
|
+
// Lockfile hash should be advanced to upstream (conflict-keep semantics)
|
|
202
|
+
const lockfile = readLockfile(fixture.dir);
|
|
203
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
204
|
+
});
|
|
205
|
+
it('non-selective mode uses batch confirm, not per-file select', async () => {
|
|
206
|
+
const original = 'export const Card = { v: 1 };';
|
|
207
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
208
|
+
fixture = createFixture({
|
|
209
|
+
config: DEFAULT_CONFIG,
|
|
210
|
+
lockfile: {
|
|
211
|
+
version: 1,
|
|
212
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
213
|
+
utils: {},
|
|
214
|
+
theme: {},
|
|
215
|
+
},
|
|
216
|
+
components: { card: { 'card.ts': original } },
|
|
217
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
218
|
+
});
|
|
219
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
220
|
+
const p = await import('@clack/prompts');
|
|
221
|
+
p.confirm.mockResolvedValue(true);
|
|
222
|
+
p.select.mockClear();
|
|
223
|
+
const { updateCommand } = await import('./update');
|
|
224
|
+
await updateCommand({});
|
|
225
|
+
expect(p.confirm).toHaveBeenCalled();
|
|
226
|
+
expect(p.select).not.toHaveBeenCalled();
|
|
227
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
228
|
+
});
|
|
229
|
+
it('shows selective-mode skip message in final summary', async () => {
|
|
230
|
+
const original = 'export const Card = { v: 1 };';
|
|
231
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
232
|
+
fixture = createFixture({
|
|
233
|
+
config: DEFAULT_CONFIG,
|
|
234
|
+
lockfile: {
|
|
235
|
+
version: 1,
|
|
236
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
237
|
+
utils: {},
|
|
238
|
+
theme: {},
|
|
239
|
+
},
|
|
240
|
+
components: { card: { 'card.ts': original } },
|
|
241
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
242
|
+
});
|
|
243
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
244
|
+
const p = await import('@clack/prompts');
|
|
245
|
+
p.select.mockResolvedValue('keep');
|
|
246
|
+
const { updateCommand } = await import('./update');
|
|
247
|
+
await updateCommand({ selective: true });
|
|
248
|
+
expect(p.log.info).toHaveBeenCalledWith(expect.stringContaining("they'll appear again on next update"));
|
|
249
|
+
});
|
|
250
|
+
it('user-modified files can be reverted to upstream in selective mode', async () => {
|
|
251
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
252
|
+
const userVersion = 'export const Card = { v: 2, custom: true };';
|
|
253
|
+
// Lockfile hash matches upstream — simulates a previous conflict-keep
|
|
254
|
+
// where the base was advanced but the user's file differs
|
|
255
|
+
fixture = createFixture({
|
|
256
|
+
config: DEFAULT_CONFIG,
|
|
257
|
+
lockfile: {
|
|
258
|
+
version: 1,
|
|
259
|
+
components: {
|
|
260
|
+
card: { 'card.ts': { hash: hashContent(upstream) } },
|
|
261
|
+
},
|
|
262
|
+
utils: {},
|
|
263
|
+
theme: {},
|
|
264
|
+
},
|
|
265
|
+
components: { card: { 'card.ts': userVersion } },
|
|
266
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
267
|
+
});
|
|
268
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
269
|
+
const p = await import('@clack/prompts');
|
|
270
|
+
p.select.mockResolvedValue('upstream');
|
|
271
|
+
const { updateCommand } = await import('./update');
|
|
272
|
+
await updateCommand({ selective: true });
|
|
273
|
+
// File should be reverted to upstream
|
|
274
|
+
const content = readInstalledFile(fixture.dir, 'card', 'card.ts');
|
|
275
|
+
expect(content).toBe(upstream);
|
|
276
|
+
const lockfile = readLockfile(fixture.dir);
|
|
277
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
/**
|
|
281
|
+
* `--force` is the destructive path: it must reset every tracked file to the
|
|
282
|
+
* upstream version and advance the lockfile, no matter how the file drifted,
|
|
283
|
+
* and it must never prompt. These tests exhaustively pin that contract because
|
|
284
|
+
* a regression here silently discards user work (or, worse, silently fails to).
|
|
285
|
+
*/
|
|
286
|
+
describe('updateCommand --force', () => {
|
|
287
|
+
const seedTheme = (fixture, fileName, content) => writeFileSync(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, fileName), content);
|
|
288
|
+
const readTheme = (fixture, fileName) => readFileSync(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, fileName), 'utf-8');
|
|
289
|
+
it('overwrites a user-modified file (local drift, upstream unchanged)', async () => {
|
|
290
|
+
// base == upstream, but the user edited their copy locally → the file
|
|
291
|
+
// analyzes as "user-modified". Without --force this drift is preserved;
|
|
292
|
+
// with --force it must be discarded and reset to upstream.
|
|
293
|
+
const base = 'export const Card = { v: 1 };';
|
|
294
|
+
const userVersion = 'export const Card = { v: 1, local: true };';
|
|
295
|
+
const upstream = base;
|
|
296
|
+
fixture = createFixture({
|
|
297
|
+
config: DEFAULT_CONFIG,
|
|
298
|
+
lockfile: {
|
|
299
|
+
version: 1,
|
|
300
|
+
components: { card: { 'card.ts': { hash: hashContent(base) } } },
|
|
301
|
+
utils: {},
|
|
302
|
+
theme: {},
|
|
303
|
+
},
|
|
304
|
+
components: { card: { 'card.ts': userVersion } },
|
|
305
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
306
|
+
});
|
|
307
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
308
|
+
const p = await import('@clack/prompts');
|
|
309
|
+
p.select.mockClear();
|
|
310
|
+
p.confirm.mockClear();
|
|
311
|
+
const { updateCommand } = await import('./update');
|
|
312
|
+
await updateCommand({ force: true });
|
|
313
|
+
// Local edit discarded, file reset to upstream.
|
|
314
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
315
|
+
// Force never prompts.
|
|
316
|
+
expect(p.select).not.toHaveBeenCalled();
|
|
317
|
+
expect(p.confirm).not.toHaveBeenCalled();
|
|
318
|
+
const lockfile = readLockfile(fixture.dir);
|
|
319
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
320
|
+
});
|
|
321
|
+
it('overwrites a conflicted file (both local and upstream changed)', async () => {
|
|
322
|
+
// current != base AND upstream != base → "conflict". Interactive mode
|
|
323
|
+
// would prompt; --force resolves every conflict in favor of upstream.
|
|
324
|
+
const base = 'export const Card = { v: 1 };';
|
|
325
|
+
const userVersion = 'export const Card = { v: 1, local: true };';
|
|
326
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
327
|
+
fixture = createFixture({
|
|
328
|
+
config: DEFAULT_CONFIG,
|
|
329
|
+
lockfile: {
|
|
330
|
+
version: 1,
|
|
331
|
+
components: { card: { 'card.ts': { hash: hashContent(base) } } },
|
|
332
|
+
utils: {},
|
|
333
|
+
theme: {},
|
|
334
|
+
},
|
|
335
|
+
components: { card: { 'card.ts': userVersion } },
|
|
336
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
337
|
+
});
|
|
338
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
339
|
+
const p = await import('@clack/prompts');
|
|
340
|
+
p.select.mockClear();
|
|
341
|
+
const { updateCommand } = await import('./update');
|
|
342
|
+
await updateCommand({ force: true });
|
|
343
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
344
|
+
expect(p.select).not.toHaveBeenCalled();
|
|
345
|
+
const lockfile = readLockfile(fixture.dir);
|
|
346
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
347
|
+
});
|
|
348
|
+
it('overwrites a drifted file with no lockfile entry (unknown base → conflict)', async () => {
|
|
349
|
+
// With no stored hash the base is unknown, so any local difference is
|
|
350
|
+
// treated as a conflict. --force must still overwrite it.
|
|
351
|
+
const userVersion = 'export const Card = { v: 1, local: true };';
|
|
352
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
353
|
+
fixture = createFixture({
|
|
354
|
+
config: DEFAULT_CONFIG,
|
|
355
|
+
lockfile: { version: 1, components: {}, utils: {}, theme: {} },
|
|
356
|
+
components: { card: { 'card.ts': userVersion } },
|
|
357
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
358
|
+
});
|
|
359
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
360
|
+
const { updateCommand } = await import('./update');
|
|
361
|
+
await updateCommand({ force: true });
|
|
362
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
363
|
+
const lockfile = readLockfile(fixture.dir);
|
|
364
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
365
|
+
});
|
|
366
|
+
it('applies a routine auto-update (disk matches base, upstream moved)', async () => {
|
|
367
|
+
const base = 'export const Card = { v: 1 };';
|
|
368
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
369
|
+
fixture = createFixture({
|
|
370
|
+
config: DEFAULT_CONFIG,
|
|
371
|
+
lockfile: {
|
|
372
|
+
version: 1,
|
|
373
|
+
components: { card: { 'card.ts': { hash: hashContent(base) } } },
|
|
374
|
+
utils: {},
|
|
375
|
+
theme: {},
|
|
376
|
+
},
|
|
377
|
+
components: { card: { 'card.ts': base } },
|
|
378
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
379
|
+
});
|
|
380
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
381
|
+
const { updateCommand } = await import('./update');
|
|
382
|
+
await updateCommand({ force: true });
|
|
383
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
384
|
+
const lockfile = readLockfile(fixture.dir);
|
|
385
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
386
|
+
});
|
|
387
|
+
it('creates a new-upstream file that is missing on disk', async () => {
|
|
388
|
+
// Component tracked, but a brand-new file exists upstream that the user
|
|
389
|
+
// has never had. Force should create it.
|
|
390
|
+
const existing = 'export const Card = { v: 1 };';
|
|
391
|
+
const brandNew = 'export const helper = () => 42;';
|
|
392
|
+
fixture = createFixture({
|
|
393
|
+
config: DEFAULT_CONFIG,
|
|
394
|
+
lockfile: {
|
|
395
|
+
version: 1,
|
|
396
|
+
components: { card: { 'card.ts': { hash: hashContent(existing) } } },
|
|
397
|
+
utils: {},
|
|
398
|
+
theme: {},
|
|
399
|
+
},
|
|
400
|
+
components: { card: { 'card.ts': existing } },
|
|
401
|
+
uiSource: { card: { 'card.ts': existing, 'helper.ts': brandNew } },
|
|
402
|
+
});
|
|
403
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
404
|
+
const { updateCommand } = await import('./update');
|
|
405
|
+
await updateCommand({ force: true });
|
|
406
|
+
expect(readInstalledFile(fixture.dir, 'card', 'helper.ts')).toBe(brandNew);
|
|
407
|
+
const lockfile = readLockfile(fixture.dir);
|
|
408
|
+
expect(lockfile.components.card['helper.ts'].hash).toBe(hashContent(brandNew));
|
|
409
|
+
});
|
|
410
|
+
it('leaves an up-to-date file untouched and records its hash', async () => {
|
|
411
|
+
const content = 'export const Card = { v: 1 };';
|
|
412
|
+
fixture = createFixture({
|
|
413
|
+
config: DEFAULT_CONFIG,
|
|
414
|
+
lockfile: {
|
|
415
|
+
version: 1,
|
|
416
|
+
components: { card: { 'card.ts': { hash: hashContent(content) } } },
|
|
417
|
+
utils: {},
|
|
418
|
+
theme: {},
|
|
419
|
+
},
|
|
420
|
+
components: { card: { 'card.ts': content } },
|
|
421
|
+
uiSource: { card: { 'card.ts': content } },
|
|
422
|
+
});
|
|
423
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
424
|
+
const { updateCommand } = await import('./update');
|
|
425
|
+
await updateCommand({ force: true });
|
|
426
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(content);
|
|
427
|
+
const lockfile = readLockfile(fixture.dir);
|
|
428
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(content));
|
|
429
|
+
});
|
|
430
|
+
it('resets every file of a multi-file component regardless of per-file status', async () => {
|
|
431
|
+
// One file conflicts, one is user-modified, one is a clean auto-update —
|
|
432
|
+
// force must bring all three to upstream in a single pass.
|
|
433
|
+
fixture = createFixture({
|
|
434
|
+
config: DEFAULT_CONFIG,
|
|
435
|
+
lockfile: {
|
|
436
|
+
version: 1,
|
|
437
|
+
components: {
|
|
438
|
+
card: {
|
|
439
|
+
'a.ts': { hash: hashContent('base-a') },
|
|
440
|
+
'b.ts': { hash: hashContent('base-b') },
|
|
441
|
+
'c.ts': { hash: hashContent('base-c') },
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
utils: {},
|
|
445
|
+
theme: {},
|
|
446
|
+
},
|
|
447
|
+
components: {
|
|
448
|
+
card: {
|
|
449
|
+
'a.ts': 'local-edit-a', // conflict (upstream also moved)
|
|
450
|
+
'b.ts': 'local-edit-b', // user-modified (upstream unchanged)
|
|
451
|
+
'c.ts': 'base-c', // auto-update (disk matches base)
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
uiSource: {
|
|
455
|
+
card: {
|
|
456
|
+
'a.ts': 'upstream-a',
|
|
457
|
+
'b.ts': 'base-b',
|
|
458
|
+
'c.ts': 'upstream-c',
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
463
|
+
const { updateCommand } = await import('./update');
|
|
464
|
+
await updateCommand({ force: true });
|
|
465
|
+
expect(readInstalledFile(fixture.dir, 'card', 'a.ts')).toBe('upstream-a');
|
|
466
|
+
expect(readInstalledFile(fixture.dir, 'card', 'b.ts')).toBe('base-b');
|
|
467
|
+
expect(readInstalledFile(fixture.dir, 'card', 'c.ts')).toBe('upstream-c');
|
|
468
|
+
const lockfile = readLockfile(fixture.dir);
|
|
469
|
+
expect(lockfile.components.card['a.ts'].hash).toBe(hashContent('upstream-a'));
|
|
470
|
+
expect(lockfile.components.card['b.ts'].hash).toBe(hashContent('base-b'));
|
|
471
|
+
expect(lockfile.components.card['c.ts'].hash).toBe(hashContent('upstream-c'));
|
|
472
|
+
});
|
|
473
|
+
it('resets multiple drifted components in a single run', async () => {
|
|
474
|
+
fixture = createFixture({
|
|
475
|
+
config: DEFAULT_CONFIG,
|
|
476
|
+
lockfile: {
|
|
477
|
+
version: 1,
|
|
478
|
+
components: {
|
|
479
|
+
card: { 'card.ts': { hash: hashContent('card-base') } },
|
|
480
|
+
spinner: { 'spinner.ts': { hash: hashContent('spinner-base') } },
|
|
481
|
+
},
|
|
482
|
+
utils: {},
|
|
483
|
+
theme: {},
|
|
484
|
+
},
|
|
485
|
+
components: {
|
|
486
|
+
card: { 'card.ts': 'card-local' },
|
|
487
|
+
spinner: { 'spinner.ts': 'spinner-local' },
|
|
488
|
+
},
|
|
489
|
+
uiSource: {
|
|
490
|
+
card: { 'card.ts': 'card-upstream' },
|
|
491
|
+
spinner: { 'spinner.ts': 'spinner-upstream' },
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
495
|
+
const { updateCommand } = await import('./update');
|
|
496
|
+
await updateCommand({ force: true });
|
|
497
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe('card-upstream');
|
|
498
|
+
expect(readInstalledFile(fixture.dir, 'spinner', 'spinner.ts')).toBe('spinner-upstream');
|
|
499
|
+
});
|
|
500
|
+
it('does not delete untracked local files sitting alongside components', async () => {
|
|
501
|
+
// Force overwrites tracked files but must not touch files the registry
|
|
502
|
+
// doesn't know about (e.g. a user's own sibling file).
|
|
503
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
504
|
+
const userExtra = 'export const mine = true;';
|
|
505
|
+
fixture = createFixture({
|
|
506
|
+
config: DEFAULT_CONFIG,
|
|
507
|
+
lockfile: {
|
|
508
|
+
version: 1,
|
|
509
|
+
components: { card: { 'card.ts': { hash: hashContent('base') } } },
|
|
510
|
+
utils: {},
|
|
511
|
+
theme: {},
|
|
512
|
+
},
|
|
513
|
+
components: {
|
|
514
|
+
card: { 'card.ts': 'local', 'extra.ts': userExtra },
|
|
515
|
+
},
|
|
516
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
517
|
+
});
|
|
518
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
519
|
+
const { updateCommand } = await import('./update');
|
|
520
|
+
await updateCommand({ force: true });
|
|
521
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
522
|
+
// Untracked sibling is left exactly as-is.
|
|
523
|
+
expect(readInstalledFile(fixture.dir, 'card', 'extra.ts')).toBe(userExtra);
|
|
524
|
+
});
|
|
525
|
+
it('is idempotent — a second force run reports nothing to do', async () => {
|
|
526
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
527
|
+
fixture = createFixture({
|
|
528
|
+
config: DEFAULT_CONFIG,
|
|
529
|
+
lockfile: {
|
|
530
|
+
version: 1,
|
|
531
|
+
components: { card: { 'card.ts': { hash: hashContent('base') } } },
|
|
532
|
+
utils: {},
|
|
533
|
+
theme: {},
|
|
534
|
+
},
|
|
535
|
+
components: { card: { 'card.ts': 'local-drift' } },
|
|
536
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
537
|
+
});
|
|
538
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
539
|
+
const { updateCommand } = await import('./update');
|
|
540
|
+
await updateCommand({ force: true });
|
|
541
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
542
|
+
// Second run: everything now matches upstream, so it must be a no-op that
|
|
543
|
+
// leaves the file (and lockfile) exactly as the first run left them.
|
|
544
|
+
await updateCommand({ force: true });
|
|
545
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
546
|
+
const lockfile = readLockfile(fixture.dir);
|
|
547
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
548
|
+
});
|
|
549
|
+
it('overwrites a user-modified shared theme file', async () => {
|
|
550
|
+
// Theme files travel through a separate apply path from component files,
|
|
551
|
+
// so force must overwrite drift there too. upstream == base → user-modified.
|
|
552
|
+
const base = ':root { --primary: rgba(1, 1, 1, 1); }';
|
|
553
|
+
const userVersion = ':root { --primary: rgba(9, 9, 9, 1); }';
|
|
554
|
+
fixture = createFixture({
|
|
555
|
+
config: DEFAULT_CONFIG,
|
|
556
|
+
lockfile: {
|
|
557
|
+
version: 1,
|
|
558
|
+
components: {},
|
|
559
|
+
utils: {},
|
|
560
|
+
theme: { 'default.css': { hash: hashContent(base) } },
|
|
561
|
+
},
|
|
562
|
+
themeFiles: { 'default.css': base },
|
|
563
|
+
});
|
|
564
|
+
seedTheme(fixture, 'default.css', userVersion);
|
|
565
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
566
|
+
const { updateCommand } = await import('./update');
|
|
567
|
+
await updateCommand({ force: true });
|
|
568
|
+
expect(readTheme(fixture, 'default.css')).toBe(base);
|
|
569
|
+
const lockfile = readLockfile(fixture.dir);
|
|
570
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(base));
|
|
571
|
+
});
|
|
572
|
+
it('overwrites a conflicted shared theme file', async () => {
|
|
573
|
+
const base = ':root { --primary: rgba(1, 1, 1, 1); }';
|
|
574
|
+
const userVersion = ':root { --primary: rgba(9, 9, 9, 1); }';
|
|
575
|
+
const upstream = ':root { --primary: rgba(2, 2, 2, 1); }';
|
|
576
|
+
fixture = createFixture({
|
|
577
|
+
config: DEFAULT_CONFIG,
|
|
578
|
+
lockfile: {
|
|
579
|
+
version: 1,
|
|
580
|
+
components: {},
|
|
581
|
+
utils: {},
|
|
582
|
+
theme: { 'default.css': { hash: hashContent(base) } },
|
|
583
|
+
},
|
|
584
|
+
themeFiles: { 'default.css': upstream },
|
|
585
|
+
});
|
|
586
|
+
seedTheme(fixture, 'default.css', userVersion);
|
|
587
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
588
|
+
const { updateCommand } = await import('./update');
|
|
589
|
+
await updateCommand({ force: true });
|
|
590
|
+
expect(readTheme(fixture, 'default.css')).toBe(upstream);
|
|
591
|
+
const lockfile = readLockfile(fixture.dir);
|
|
592
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(upstream));
|
|
593
|
+
});
|
|
594
|
+
it('creates a missing shared theme file from upstream', async () => {
|
|
595
|
+
const upstream = ':root { --primary: rgba(2, 2, 2, 1); }';
|
|
596
|
+
fixture = createFixture({
|
|
597
|
+
config: DEFAULT_CONFIG,
|
|
598
|
+
lockfile: { version: 1, components: {}, utils: {}, theme: {} },
|
|
599
|
+
themeFiles: { 'default.css': upstream },
|
|
600
|
+
});
|
|
601
|
+
// No default.css seeded on disk → new-upstream.
|
|
602
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
603
|
+
const { updateCommand } = await import('./update');
|
|
604
|
+
await updateCommand({ force: true });
|
|
605
|
+
expect(readTheme(fixture, 'default.css')).toBe(upstream);
|
|
606
|
+
const lockfile = readLockfile(fixture.dir);
|
|
607
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(upstream));
|
|
608
|
+
});
|
|
609
|
+
it('never prompts even when conflicts, user-modifications and new files coexist', async () => {
|
|
610
|
+
fixture = createFixture({
|
|
611
|
+
config: DEFAULT_CONFIG,
|
|
612
|
+
lockfile: {
|
|
613
|
+
version: 1,
|
|
614
|
+
components: {
|
|
615
|
+
card: {
|
|
616
|
+
'card.ts': { hash: hashContent('card-base') },
|
|
617
|
+
'util.ts': { hash: hashContent('util-base') },
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
utils: {},
|
|
621
|
+
theme: { 'default.css': { hash: hashContent('theme-base') } },
|
|
622
|
+
},
|
|
623
|
+
components: {
|
|
624
|
+
card: { 'card.ts': 'card-local', 'util.ts': 'util-base' },
|
|
625
|
+
},
|
|
626
|
+
uiSource: {
|
|
627
|
+
card: {
|
|
628
|
+
'card.ts': 'card-upstream', // conflict
|
|
629
|
+
'util.ts': 'util-upstream', // auto-update
|
|
630
|
+
'new.ts': 'new-file', // new-upstream
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
themeFiles: { 'default.css': 'theme-upstream' },
|
|
634
|
+
});
|
|
635
|
+
seedTheme(fixture, 'default.css', 'theme-local'); // theme conflict
|
|
636
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
637
|
+
const p = await import('@clack/prompts');
|
|
638
|
+
p.select.mockClear();
|
|
639
|
+
p.confirm.mockClear();
|
|
640
|
+
const { updateCommand } = await import('./update');
|
|
641
|
+
await updateCommand({ force: true });
|
|
642
|
+
expect(p.select).not.toHaveBeenCalled();
|
|
643
|
+
expect(p.confirm).not.toHaveBeenCalled();
|
|
644
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe('card-upstream');
|
|
645
|
+
expect(readInstalledFile(fixture.dir, 'card', 'util.ts')).toBe('util-upstream');
|
|
646
|
+
expect(readInstalledFile(fixture.dir, 'card', 'new.ts')).toBe('new-file');
|
|
647
|
+
expect(readTheme(fixture, 'default.css')).toBe('theme-upstream');
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
/**
|
|
651
|
+
* Multi-line content with two separate change regions (hunks) separated
|
|
652
|
+
* by enough unchanged lines that structuredPatch produces 2 distinct hunks.
|
|
653
|
+
*/
|
|
654
|
+
const makeMultiHunkContent = () => {
|
|
655
|
+
const lines = (changes) => {
|
|
656
|
+
const base = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
|
657
|
+
for (const [idx, value] of changes) {
|
|
658
|
+
base[idx] = value;
|
|
659
|
+
}
|
|
660
|
+
return base.join('\n') + '\n';
|
|
661
|
+
};
|
|
662
|
+
return {
|
|
663
|
+
original: lines([]),
|
|
664
|
+
upstream: lines([
|
|
665
|
+
[1, 'CHANGED line 2'],
|
|
666
|
+
[18, 'CHANGED line 19'],
|
|
667
|
+
]),
|
|
668
|
+
};
|
|
669
|
+
};
|
|
670
|
+
describe('updateCommand --selective hunk review', () => {
|
|
671
|
+
it('partially applies only accepted hunks', async () => {
|
|
672
|
+
const { original, upstream } = makeMultiHunkContent();
|
|
673
|
+
fixture = createFixture({
|
|
674
|
+
config: DEFAULT_CONFIG,
|
|
675
|
+
lockfile: {
|
|
676
|
+
version: 1,
|
|
677
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
678
|
+
utils: {},
|
|
679
|
+
theme: {},
|
|
680
|
+
},
|
|
681
|
+
components: { card: { 'card.ts': original } },
|
|
682
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
683
|
+
});
|
|
684
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
685
|
+
const p = await import('@clack/prompts');
|
|
686
|
+
p.select
|
|
687
|
+
// File-level prompt → choose hunk review
|
|
688
|
+
.mockResolvedValueOnce('hunk-review')
|
|
689
|
+
// Hunk 1 → accept
|
|
690
|
+
.mockResolvedValueOnce('accept')
|
|
691
|
+
// Hunk 2 → reject
|
|
692
|
+
.mockResolvedValueOnce('reject');
|
|
693
|
+
const { updateCommand } = await import('./update');
|
|
694
|
+
await updateCommand({ selective: true });
|
|
695
|
+
const content = readInstalledFile(fixture.dir, 'card', 'card.ts');
|
|
696
|
+
// First hunk accepted: line 2 should be changed
|
|
697
|
+
expect(content).toContain('CHANGED line 2');
|
|
698
|
+
// Second hunk rejected: line 19 should be original
|
|
699
|
+
expect(content).toContain('line 19');
|
|
700
|
+
expect(content).not.toContain('CHANGED line 19');
|
|
701
|
+
// Lockfile should be advanced to upstream hash
|
|
702
|
+
const lockfile = readLockfile(fixture.dir);
|
|
703
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(upstream));
|
|
704
|
+
});
|
|
705
|
+
it('treats all hunks accepted as full upstream apply', async () => {
|
|
706
|
+
const { original, upstream } = makeMultiHunkContent();
|
|
707
|
+
fixture = createFixture({
|
|
708
|
+
config: DEFAULT_CONFIG,
|
|
709
|
+
lockfile: {
|
|
710
|
+
version: 1,
|
|
711
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
712
|
+
utils: {},
|
|
713
|
+
theme: {},
|
|
714
|
+
},
|
|
715
|
+
components: { card: { 'card.ts': original } },
|
|
716
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
717
|
+
});
|
|
718
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
719
|
+
const p = await import('@clack/prompts');
|
|
720
|
+
p.select
|
|
721
|
+
.mockResolvedValueOnce('hunk-review')
|
|
722
|
+
.mockResolvedValueOnce('accept')
|
|
723
|
+
.mockResolvedValueOnce('accept');
|
|
724
|
+
const { updateCommand } = await import('./update');
|
|
725
|
+
await updateCommand({ selective: true });
|
|
726
|
+
const content = readInstalledFile(fixture.dir, 'card', 'card.ts');
|
|
727
|
+
expect(content).toBe(upstream);
|
|
728
|
+
});
|
|
729
|
+
it('treats all hunks rejected as keep', async () => {
|
|
730
|
+
const { original, upstream } = makeMultiHunkContent();
|
|
731
|
+
const originalHash = hashContent(original);
|
|
732
|
+
fixture = createFixture({
|
|
733
|
+
config: DEFAULT_CONFIG,
|
|
734
|
+
lockfile: {
|
|
735
|
+
version: 1,
|
|
736
|
+
components: { card: { 'card.ts': { hash: originalHash } } },
|
|
737
|
+
utils: {},
|
|
738
|
+
theme: {},
|
|
739
|
+
},
|
|
740
|
+
components: { card: { 'card.ts': original } },
|
|
741
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
742
|
+
});
|
|
743
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
744
|
+
const p = await import('@clack/prompts');
|
|
745
|
+
p.select
|
|
746
|
+
.mockResolvedValueOnce('hunk-review')
|
|
747
|
+
.mockResolvedValueOnce('reject')
|
|
748
|
+
.mockResolvedValueOnce('reject');
|
|
749
|
+
const { updateCommand } = await import('./update');
|
|
750
|
+
await updateCommand({ selective: true });
|
|
751
|
+
const content = readInstalledFile(fixture.dir, 'card', 'card.ts');
|
|
752
|
+
expect(content).toBe(original);
|
|
753
|
+
// Lockfile should be preserved (selective skip semantics)
|
|
754
|
+
const lockfile = readLockfile(fixture.dir);
|
|
755
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(originalHash);
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
describe('updateCommand — config / no-op paths', () => {
|
|
759
|
+
it('exits with error when no config exists', async () => {
|
|
760
|
+
fixture = createFixture({});
|
|
761
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
762
|
+
const p = await import('@clack/prompts');
|
|
763
|
+
const { updateCommand } = await import('./update');
|
|
764
|
+
await expect(updateCommand({})).rejects.toThrow('process.exit(1)');
|
|
765
|
+
expect(p.intro).toHaveBeenCalled();
|
|
766
|
+
expect(p.log.error).toHaveBeenCalledWith(expect.stringContaining('dolan.config.json'));
|
|
767
|
+
});
|
|
768
|
+
it('reports nothing to do when only user-modified files exist (no force, no selective)', async () => {
|
|
769
|
+
const base = 'export const Badge = { v: 1 };';
|
|
770
|
+
const userVersion = 'export const Badge = { v: 1, custom: true };';
|
|
771
|
+
fixture = createFixture({
|
|
772
|
+
config: DEFAULT_CONFIG,
|
|
773
|
+
lockfile: {
|
|
774
|
+
version: 1,
|
|
775
|
+
components: { badge: { 'badge.ts': { hash: hashContent(base) } } },
|
|
776
|
+
utils: {},
|
|
777
|
+
theme: {},
|
|
778
|
+
},
|
|
779
|
+
components: { badge: { 'badge.ts': userVersion } },
|
|
780
|
+
uiSource: { badge: { 'badge.ts': base } },
|
|
781
|
+
});
|
|
782
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
783
|
+
const p = await import('@clack/prompts');
|
|
784
|
+
p.confirm.mockClear();
|
|
785
|
+
p.select.mockClear();
|
|
786
|
+
const { updateCommand } = await import('./update');
|
|
787
|
+
await updateCommand({});
|
|
788
|
+
// Nothing prompts — a lone user-modified file with no force/selective is
|
|
789
|
+
// treated as "nothing actionable," not something to resolve.
|
|
790
|
+
expect(p.confirm).not.toHaveBeenCalled();
|
|
791
|
+
expect(p.select).not.toHaveBeenCalled();
|
|
792
|
+
expect(p.outro).toHaveBeenCalledWith(expect.stringContaining('Everything is up to date'));
|
|
793
|
+
// The user's file is left completely untouched.
|
|
794
|
+
expect(readInstalledFile(fixture.dir, 'badge', 'badge.ts')).toBe(userVersion);
|
|
795
|
+
});
|
|
796
|
+
it('cancels when the user declines the batch auto-update confirmation', async () => {
|
|
797
|
+
const base = 'export const Card = { v: 1 };';
|
|
798
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
799
|
+
fixture = createFixture({
|
|
800
|
+
config: DEFAULT_CONFIG,
|
|
801
|
+
lockfile: {
|
|
802
|
+
version: 1,
|
|
803
|
+
components: { card: { 'card.ts': { hash: hashContent(base) } } },
|
|
804
|
+
utils: {},
|
|
805
|
+
theme: {},
|
|
806
|
+
},
|
|
807
|
+
components: { card: { 'card.ts': base } },
|
|
808
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
809
|
+
});
|
|
810
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
811
|
+
const p = await import('@clack/prompts');
|
|
812
|
+
p.confirm.mockResolvedValueOnce(false);
|
|
813
|
+
const { updateCommand } = await import('./update');
|
|
814
|
+
await expect(updateCommand({})).rejects.toThrow('process.exit(0)');
|
|
815
|
+
expect(p.cancel).toHaveBeenCalledWith(expect.stringContaining('Update canceled'));
|
|
816
|
+
// Untouched — the confirm was declined before anything was written.
|
|
817
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(base);
|
|
818
|
+
});
|
|
819
|
+
it('cancels when the batch auto-update confirmation prompt itself is canceled', async () => {
|
|
820
|
+
const base = 'export const Card = { v: 1 };';
|
|
821
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
822
|
+
fixture = createFixture({
|
|
823
|
+
config: DEFAULT_CONFIG,
|
|
824
|
+
lockfile: {
|
|
825
|
+
version: 1,
|
|
826
|
+
components: { card: { 'card.ts': { hash: hashContent(base) } } },
|
|
827
|
+
utils: {},
|
|
828
|
+
theme: {},
|
|
829
|
+
},
|
|
830
|
+
components: { card: { 'card.ts': base } },
|
|
831
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
832
|
+
});
|
|
833
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
834
|
+
const p = await import('@clack/prompts');
|
|
835
|
+
p.isCancel.mockReturnValueOnce(true);
|
|
836
|
+
const { updateCommand } = await import('./update');
|
|
837
|
+
await expect(updateCommand({})).rejects.toThrow('process.exit(0)');
|
|
838
|
+
});
|
|
839
|
+
it('keeps an unrelated user-modified file untouched alongside a batch auto-update, and records an up-to-date theme file', async () => {
|
|
840
|
+
// card is a routine auto-update (drives the batch confirm path); badge is
|
|
841
|
+
// user-modified with nothing to do about it outside selective/force mode
|
|
842
|
+
// — it must fall through to the "kept as-is" bookkeeping branch instead of
|
|
843
|
+
// being silently dropped. The theme file is already up to date, which
|
|
844
|
+
// only gets exercised because *something* else keeps this update from
|
|
845
|
+
// short-circuiting at "everything is up to date".
|
|
846
|
+
const cardBase = 'export const Card = { v: 1 };';
|
|
847
|
+
const cardUpstream = 'export const Card = { v: 2 };';
|
|
848
|
+
const badgeBase = 'export const Badge = { v: 1 };';
|
|
849
|
+
const badgeUserVersion = 'export const Badge = { v: 1, custom: true };';
|
|
850
|
+
const themeContent = ':root { --primary: rgba(1, 1, 1, 1); }';
|
|
851
|
+
fixture = createFixture({
|
|
852
|
+
config: DEFAULT_CONFIG,
|
|
853
|
+
lockfile: {
|
|
854
|
+
version: 1,
|
|
855
|
+
components: {
|
|
856
|
+
card: { 'card.ts': { hash: hashContent(cardBase) } },
|
|
857
|
+
badge: { 'badge.ts': { hash: hashContent(badgeBase) } },
|
|
858
|
+
},
|
|
859
|
+
utils: {},
|
|
860
|
+
theme: { 'default.css': { hash: hashContent(themeContent) } },
|
|
861
|
+
},
|
|
862
|
+
components: {
|
|
863
|
+
card: { 'card.ts': cardBase },
|
|
864
|
+
badge: { 'badge.ts': badgeUserVersion },
|
|
865
|
+
},
|
|
866
|
+
uiSource: {
|
|
867
|
+
card: { 'card.ts': cardUpstream },
|
|
868
|
+
badge: { 'badge.ts': badgeBase },
|
|
869
|
+
},
|
|
870
|
+
themeFiles: { 'default.css': themeContent },
|
|
871
|
+
});
|
|
872
|
+
const { writeFileSync: seedFile } = await import('node:fs');
|
|
873
|
+
seedFile(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), themeContent);
|
|
874
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
875
|
+
const p = await import('@clack/prompts');
|
|
876
|
+
p.confirm.mockResolvedValueOnce(true);
|
|
877
|
+
p.select.mockClear();
|
|
878
|
+
const { updateCommand } = await import('./update');
|
|
879
|
+
await updateCommand({});
|
|
880
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(cardUpstream);
|
|
881
|
+
// Badge is untouched — no force, no selective, nothing to resolve.
|
|
882
|
+
expect(readInstalledFile(fixture.dir, 'badge', 'badge.ts')).toBe(badgeUserVersion);
|
|
883
|
+
expect(p.log.info).toHaveBeenCalledWith(expect.stringContaining('kept as-is'));
|
|
884
|
+
const lockfile = readLockfile(fixture.dir);
|
|
885
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(cardUpstream));
|
|
886
|
+
// Badge's lockfile hash is preserved as-is (still points at its base).
|
|
887
|
+
expect(lockfile.components.badge['badge.ts'].hash).toBe(hashContent(badgeBase));
|
|
888
|
+
// The already up-to-date theme file's hash is still recorded.
|
|
889
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(themeContent));
|
|
890
|
+
});
|
|
891
|
+
it('advances the lockfile for an up-to-date component and theme file with no prior lockfile entries', async () => {
|
|
892
|
+
// Neither the component nor the theme file has ANY lockfile record at
|
|
893
|
+
// all (not just a missing hash) — buildLockfile() must create fresh
|
|
894
|
+
// entries for both from scratch on the "nothing to do" fast path.
|
|
895
|
+
const cardContent = 'export const Card = {};';
|
|
896
|
+
const themeContent = ':root { --primary: rgba(1, 1, 1, 1); }';
|
|
897
|
+
fixture = createFixture({
|
|
898
|
+
config: DEFAULT_CONFIG,
|
|
899
|
+
lockfile: { version: 1, components: {}, utils: {}, theme: {} },
|
|
900
|
+
components: { card: { 'card.ts': cardContent } },
|
|
901
|
+
uiSource: { card: { 'card.ts': cardContent } },
|
|
902
|
+
themeFiles: { 'default.css': themeContent },
|
|
903
|
+
});
|
|
904
|
+
const { writeFileSync: seedFile } = await import('node:fs');
|
|
905
|
+
seedFile(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), themeContent);
|
|
906
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
907
|
+
const p = await import('@clack/prompts');
|
|
908
|
+
const { updateCommand } = await import('./update');
|
|
909
|
+
await updateCommand({});
|
|
910
|
+
expect(p.outro).toHaveBeenCalledWith(expect.stringContaining('Everything is up to date'));
|
|
911
|
+
const lockfile = readLockfile(fixture.dir);
|
|
912
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(cardContent));
|
|
913
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(themeContent));
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
/**
|
|
917
|
+
* Two independent edits far enough apart (given `context`) that
|
|
918
|
+
* structuredPatch always produces two distinct, non-adjacent hunks — one
|
|
919
|
+
* hook for user edits, one for upstream edits, so tests can target either
|
|
920
|
+
* independently.
|
|
921
|
+
*/
|
|
922
|
+
const makeTwoRegionContent = () => {
|
|
923
|
+
const base = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
|
924
|
+
const current = [...base];
|
|
925
|
+
current[4] = 'USER change line 5';
|
|
926
|
+
const upstream = [...base];
|
|
927
|
+
upstream[14] = 'UPSTREAM change line 15';
|
|
928
|
+
return {
|
|
929
|
+
base: base.join('\n') + '\n',
|
|
930
|
+
current: current.join('\n') + '\n',
|
|
931
|
+
upstream: upstream.join('\n') + '\n',
|
|
932
|
+
};
|
|
933
|
+
};
|
|
934
|
+
describe('updateCommand — hunk review edge cases', () => {
|
|
935
|
+
it('cancels mid hunk-review when the user cancels a hunk prompt', async () => {
|
|
936
|
+
const { original, upstream } = makeMultiHunkContent();
|
|
937
|
+
fixture = createFixture({
|
|
938
|
+
config: DEFAULT_CONFIG,
|
|
939
|
+
lockfile: {
|
|
940
|
+
version: 1,
|
|
941
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
942
|
+
utils: {},
|
|
943
|
+
theme: {},
|
|
944
|
+
},
|
|
945
|
+
components: { card: { 'card.ts': original } },
|
|
946
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
947
|
+
});
|
|
948
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
949
|
+
const p = await import('@clack/prompts');
|
|
950
|
+
p.select.mockResolvedValueOnce('hunk-review');
|
|
951
|
+
// First isCancel check is for the file-level 'hunk-review' choice itself
|
|
952
|
+
// (not canceled); the second is for the first hunk prompt (canceled).
|
|
953
|
+
p.isCancel
|
|
954
|
+
.mockReturnValueOnce(false)
|
|
955
|
+
.mockReturnValueOnce(true);
|
|
956
|
+
const { updateCommand } = await import('./update');
|
|
957
|
+
await expect(updateCommand({ selective: true })).rejects.toThrow('process.exit(0)');
|
|
958
|
+
expect(p.cancel).toHaveBeenCalledWith(expect.stringContaining('Update canceled'));
|
|
959
|
+
// Untouched — canceled before any hunk decision was applied.
|
|
960
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(original);
|
|
961
|
+
});
|
|
962
|
+
it('expands context when the user asks to see more, then accepts every hunk', async () => {
|
|
963
|
+
const { original, upstream } = makeMultiHunkContent();
|
|
964
|
+
fixture = createFixture({
|
|
965
|
+
config: DEFAULT_CONFIG,
|
|
966
|
+
lockfile: {
|
|
967
|
+
version: 1,
|
|
968
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
969
|
+
utils: {},
|
|
970
|
+
theme: {},
|
|
971
|
+
},
|
|
972
|
+
components: { card: { 'card.ts': original } },
|
|
973
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
974
|
+
});
|
|
975
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
976
|
+
const p = await import('@clack/prompts');
|
|
977
|
+
p.select
|
|
978
|
+
.mockResolvedValueOnce('hunk-review')
|
|
979
|
+
// Hunk 1: ask for more context first, then accept.
|
|
980
|
+
.mockResolvedValueOnce('context')
|
|
981
|
+
.mockResolvedValueOnce('accept')
|
|
982
|
+
// Hunk 2: accept directly.
|
|
983
|
+
.mockResolvedValueOnce('accept');
|
|
984
|
+
const { updateCommand } = await import('./update');
|
|
985
|
+
await updateCommand({ selective: true });
|
|
986
|
+
const content = readInstalledFile(fixture.dir, 'card', 'card.ts');
|
|
987
|
+
expect(content).toBe(upstream);
|
|
988
|
+
});
|
|
989
|
+
it('keeps the current version when hunk reconstruction fails to apply', async () => {
|
|
990
|
+
// A defensive fallback for when applySelectedHunks() can't reconstruct a
|
|
991
|
+
// valid file from the accepted subset of hunks. Simulated via a spy since
|
|
992
|
+
// it's not practically reachable through real diff/patch content.
|
|
993
|
+
const { original, upstream } = makeMultiHunkContent();
|
|
994
|
+
fixture = createFixture({
|
|
995
|
+
config: DEFAULT_CONFIG,
|
|
996
|
+
lockfile: {
|
|
997
|
+
version: 1,
|
|
998
|
+
components: { card: { 'card.ts': { hash: hashContent(original) } } },
|
|
999
|
+
utils: {},
|
|
1000
|
+
theme: {},
|
|
1001
|
+
},
|
|
1002
|
+
components: { card: { 'card.ts': original } },
|
|
1003
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
1004
|
+
});
|
|
1005
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
1006
|
+
const analyzeUtils = await import('../utils/analyze.js');
|
|
1007
|
+
const applySpy = vi
|
|
1008
|
+
.spyOn(analyzeUtils, 'applySelectedHunks')
|
|
1009
|
+
.mockReturnValue(false);
|
|
1010
|
+
const p = await import('@clack/prompts');
|
|
1011
|
+
p.select
|
|
1012
|
+
.mockResolvedValueOnce('hunk-review')
|
|
1013
|
+
.mockResolvedValueOnce('accept')
|
|
1014
|
+
.mockResolvedValueOnce('reject');
|
|
1015
|
+
const { updateCommand } = await import('./update');
|
|
1016
|
+
await updateCommand({ selective: true });
|
|
1017
|
+
expect(applySpy).toHaveBeenCalled();
|
|
1018
|
+
expect(p.log.warn).toHaveBeenCalledWith(expect.stringContaining('Could not apply selected hunks'));
|
|
1019
|
+
// Falls back to keeping the user's current file untouched.
|
|
1020
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(original);
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
describe('updateCommand — file review "show diff" branch', () => {
|
|
1024
|
+
it('shows a diff and loops back for an existing changed file before applying upstream', async () => {
|
|
1025
|
+
const base = 'export const Card = { v: 1 };';
|
|
1026
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
1027
|
+
fixture = createFixture({
|
|
1028
|
+
config: DEFAULT_CONFIG,
|
|
1029
|
+
lockfile: {
|
|
1030
|
+
version: 1,
|
|
1031
|
+
components: { card: { 'card.ts': { hash: hashContent(base) } } },
|
|
1032
|
+
utils: {},
|
|
1033
|
+
theme: {},
|
|
1034
|
+
},
|
|
1035
|
+
components: { card: { 'card.ts': base } },
|
|
1036
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
1037
|
+
});
|
|
1038
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
1039
|
+
const p = await import('@clack/prompts');
|
|
1040
|
+
p.select
|
|
1041
|
+
.mockResolvedValueOnce('diff')
|
|
1042
|
+
.mockResolvedValueOnce('upstream');
|
|
1043
|
+
const { updateCommand } = await import('./update');
|
|
1044
|
+
await updateCommand({ selective: true });
|
|
1045
|
+
const messages = p.log.message.mock.calls
|
|
1046
|
+
.map((c) => c[0])
|
|
1047
|
+
.join('\n');
|
|
1048
|
+
expect(messages).toContain('Diff');
|
|
1049
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(upstream);
|
|
1050
|
+
});
|
|
1051
|
+
it('shows the full new-file preview and loops back before adding it', async () => {
|
|
1052
|
+
const existing = 'export const Card = { v: 1 };';
|
|
1053
|
+
const brandNew = 'export const helper = () => 42;';
|
|
1054
|
+
fixture = createFixture({
|
|
1055
|
+
config: DEFAULT_CONFIG,
|
|
1056
|
+
lockfile: {
|
|
1057
|
+
version: 1,
|
|
1058
|
+
components: { card: { 'card.ts': { hash: hashContent(existing) } } },
|
|
1059
|
+
utils: {},
|
|
1060
|
+
theme: {},
|
|
1061
|
+
},
|
|
1062
|
+
components: { card: { 'card.ts': existing } },
|
|
1063
|
+
uiSource: { card: { 'card.ts': existing, 'helper.ts': brandNew } },
|
|
1064
|
+
});
|
|
1065
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
1066
|
+
const p = await import('@clack/prompts');
|
|
1067
|
+
p.select
|
|
1068
|
+
.mockResolvedValueOnce('diff')
|
|
1069
|
+
.mockResolvedValueOnce('upstream');
|
|
1070
|
+
const { updateCommand } = await import('./update');
|
|
1071
|
+
await updateCommand({ selective: true });
|
|
1072
|
+
const messages = p.log.message.mock.calls
|
|
1073
|
+
.map((c) => c[0])
|
|
1074
|
+
.join('\n');
|
|
1075
|
+
expect(messages).toContain('New file');
|
|
1076
|
+
expect(readInstalledFile(fixture.dir, 'card', 'helper.ts')).toBe(brandNew);
|
|
1077
|
+
});
|
|
1078
|
+
it('cancels the file-level review prompt', async () => {
|
|
1079
|
+
const base = 'export const Card = { v: 1 };';
|
|
1080
|
+
const upstream = 'export const Card = { v: 2 };';
|
|
1081
|
+
fixture = createFixture({
|
|
1082
|
+
config: DEFAULT_CONFIG,
|
|
1083
|
+
lockfile: {
|
|
1084
|
+
version: 1,
|
|
1085
|
+
components: { card: { 'card.ts': { hash: hashContent(base) } } },
|
|
1086
|
+
utils: {},
|
|
1087
|
+
theme: {},
|
|
1088
|
+
},
|
|
1089
|
+
components: { card: { 'card.ts': base } },
|
|
1090
|
+
uiSource: { card: { 'card.ts': upstream } },
|
|
1091
|
+
});
|
|
1092
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
1093
|
+
const p = await import('@clack/prompts');
|
|
1094
|
+
p.isCancel.mockReturnValueOnce(true);
|
|
1095
|
+
const { updateCommand } = await import('./update');
|
|
1096
|
+
await expect(updateCommand({ selective: true })).rejects.toThrow('process.exit(0)');
|
|
1097
|
+
expect(readInstalledFile(fixture.dir, 'card', 'card.ts')).toBe(base);
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
1100
|
+
describe('updateCommand — conflict resolution outside selective mode', () => {
|
|
1101
|
+
it('applies a partial hunk resolution to both a conflicting component file and a conflicting shared theme file', async () => {
|
|
1102
|
+
const card = makeTwoRegionContent();
|
|
1103
|
+
const theme = makeTwoRegionContent();
|
|
1104
|
+
fixture = createFixture({
|
|
1105
|
+
config: DEFAULT_CONFIG,
|
|
1106
|
+
lockfile: {
|
|
1107
|
+
version: 1,
|
|
1108
|
+
components: { card: { 'card.ts': { hash: hashContent(card.base) } } },
|
|
1109
|
+
utils: {},
|
|
1110
|
+
theme: { 'default.css': { hash: hashContent(theme.base) } },
|
|
1111
|
+
},
|
|
1112
|
+
components: { card: { 'card.ts': card.current } },
|
|
1113
|
+
uiSource: { card: { 'card.ts': card.upstream } },
|
|
1114
|
+
themeFiles: { 'default.css': theme.upstream },
|
|
1115
|
+
});
|
|
1116
|
+
const { writeFileSync: seedFile } = await import('node:fs');
|
|
1117
|
+
seedFile(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), theme.current);
|
|
1118
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
1119
|
+
const p = await import('@clack/prompts');
|
|
1120
|
+
p.select
|
|
1121
|
+
// card.ts: conflict → hunk review → reject the user-edit hunk, accept
|
|
1122
|
+
// the upstream-change hunk.
|
|
1123
|
+
.mockResolvedValueOnce('hunk-review')
|
|
1124
|
+
.mockResolvedValueOnce('reject')
|
|
1125
|
+
.mockResolvedValueOnce('accept')
|
|
1126
|
+
// theme/default.css: same pattern.
|
|
1127
|
+
.mockResolvedValueOnce('hunk-review')
|
|
1128
|
+
.mockResolvedValueOnce('reject')
|
|
1129
|
+
.mockResolvedValueOnce('accept');
|
|
1130
|
+
const { updateCommand } = await import('./update');
|
|
1131
|
+
// Not selective, not force — only the conflict-resolution pass runs.
|
|
1132
|
+
await updateCommand({});
|
|
1133
|
+
const cardContent = readInstalledFile(fixture.dir, 'card', 'card.ts');
|
|
1134
|
+
// User's own edit (line 5) is preserved; upstream's real change (line 15)
|
|
1135
|
+
// is merged in.
|
|
1136
|
+
expect(cardContent).toContain('USER change line 5');
|
|
1137
|
+
expect(cardContent).toContain('UPSTREAM change line 15');
|
|
1138
|
+
const themeContent = readFileSync(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), 'utf-8');
|
|
1139
|
+
expect(themeContent).toContain('USER change line 5');
|
|
1140
|
+
expect(themeContent).toContain('UPSTREAM change line 15');
|
|
1141
|
+
const lockfile = readLockfile(fixture.dir);
|
|
1142
|
+
// Partial resolutions still advance the lockfile base to upstream.
|
|
1143
|
+
expect(lockfile.components.card['card.ts'].hash).toBe(hashContent(card.upstream));
|
|
1144
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(theme.upstream));
|
|
1145
|
+
});
|
|
1146
|
+
it('keeps a conflicting shared theme file as-is when the user chooses to keep it', async () => {
|
|
1147
|
+
const base = ':root { --primary: rgba(1, 1, 1, 1); }';
|
|
1148
|
+
const userVersion = ':root { --primary: rgba(9, 9, 9, 1); }';
|
|
1149
|
+
const upstream = ':root { --primary: rgba(2, 2, 2, 1); }';
|
|
1150
|
+
fixture = createFixture({
|
|
1151
|
+
config: DEFAULT_CONFIG,
|
|
1152
|
+
lockfile: {
|
|
1153
|
+
version: 1,
|
|
1154
|
+
components: {},
|
|
1155
|
+
utils: {},
|
|
1156
|
+
theme: { 'default.css': { hash: hashContent(base) } },
|
|
1157
|
+
},
|
|
1158
|
+
themeFiles: { 'default.css': upstream },
|
|
1159
|
+
});
|
|
1160
|
+
const { writeFileSync: seedFile } = await import('node:fs');
|
|
1161
|
+
seedFile(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), userVersion);
|
|
1162
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
1163
|
+
const p = await import('@clack/prompts');
|
|
1164
|
+
p.select.mockResolvedValueOnce('keep');
|
|
1165
|
+
const { updateCommand } = await import('./update');
|
|
1166
|
+
await updateCommand({});
|
|
1167
|
+
const themeContent = readFileSync(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), 'utf-8');
|
|
1168
|
+
expect(themeContent).toBe(userVersion);
|
|
1169
|
+
// Lockfile base still advances to upstream (conflict-keep semantics),
|
|
1170
|
+
// same as the component-file equivalent.
|
|
1171
|
+
const lockfile = readLockfile(fixture.dir);
|
|
1172
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(upstream));
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
describe('updateCommand — selective review of shared theme files', () => {
|
|
1176
|
+
it('skips a shared theme file in selective mode when the user chooses to skip it', async () => {
|
|
1177
|
+
const base = ':root { --primary: rgba(1, 1, 1, 1); }';
|
|
1178
|
+
const upstream = ':root { --primary: rgba(2, 2, 2, 1); }';
|
|
1179
|
+
fixture = createFixture({
|
|
1180
|
+
config: DEFAULT_CONFIG,
|
|
1181
|
+
lockfile: {
|
|
1182
|
+
version: 1,
|
|
1183
|
+
components: {},
|
|
1184
|
+
utils: {},
|
|
1185
|
+
theme: { 'default.css': { hash: hashContent(base) } },
|
|
1186
|
+
},
|
|
1187
|
+
themeFiles: { 'default.css': upstream },
|
|
1188
|
+
});
|
|
1189
|
+
const { writeFileSync: seedFile } = await import('node:fs');
|
|
1190
|
+
seedFile(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), base);
|
|
1191
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
1192
|
+
const p = await import('@clack/prompts');
|
|
1193
|
+
p.select.mockResolvedValueOnce('keep');
|
|
1194
|
+
const { updateCommand } = await import('./update');
|
|
1195
|
+
await updateCommand({ selective: true });
|
|
1196
|
+
const themeContent = readFileSync(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), 'utf-8');
|
|
1197
|
+
// Skipped — left exactly as it was.
|
|
1198
|
+
expect(themeContent).toBe(base);
|
|
1199
|
+
// Lockfile hash preserved at its old base so it surfaces again next run.
|
|
1200
|
+
const lockfile = readLockfile(fixture.dir);
|
|
1201
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(base));
|
|
1202
|
+
expect(p.log.info).toHaveBeenCalledWith(expect.stringContaining("they'll appear again on next update"));
|
|
1203
|
+
});
|
|
1204
|
+
it('partially applies hunks to a shared theme file reviewed in selective mode', async () => {
|
|
1205
|
+
const { original, upstream } = makeMultiHunkContent();
|
|
1206
|
+
fixture = createFixture({
|
|
1207
|
+
config: DEFAULT_CONFIG,
|
|
1208
|
+
lockfile: {
|
|
1209
|
+
version: 1,
|
|
1210
|
+
components: {},
|
|
1211
|
+
utils: {},
|
|
1212
|
+
theme: { 'default.css': { hash: hashContent(original) } },
|
|
1213
|
+
},
|
|
1214
|
+
themeFiles: { 'default.css': upstream },
|
|
1215
|
+
});
|
|
1216
|
+
const { writeFileSync: seedFile } = await import('node:fs');
|
|
1217
|
+
seedFile(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), original);
|
|
1218
|
+
vi.spyOn(process, 'cwd').mockReturnValue(fixture.dir);
|
|
1219
|
+
const p = await import('@clack/prompts');
|
|
1220
|
+
p.select
|
|
1221
|
+
.mockResolvedValueOnce('hunk-review')
|
|
1222
|
+
.mockResolvedValueOnce('accept')
|
|
1223
|
+
.mockResolvedValueOnce('reject');
|
|
1224
|
+
const { updateCommand } = await import('./update');
|
|
1225
|
+
await updateCommand({ selective: true });
|
|
1226
|
+
const themeContent = readFileSync(join(fixture.dir, DEFAULT_CONFIG.aliases.theme, 'default.css'), 'utf-8');
|
|
1227
|
+
expect(themeContent).toContain('CHANGED line 2');
|
|
1228
|
+
expect(themeContent).not.toContain('CHANGED line 19');
|
|
1229
|
+
const lockfile = readLockfile(fixture.dir);
|
|
1230
|
+
expect(lockfile.theme['default.css'].hash).toBe(hashContent(upstream));
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
//# sourceMappingURL=update.test.js.map
|