@celilo/cli 0.5.0-alpha.9 → 0.6.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.
@@ -1,252 +1,82 @@
1
- /**
2
- * Unit tests for the version-change classifier used by `module update`'s
3
- * registry-sweep mode, plus integration tests for `upgradeOne`'s
4
- * `displayVersion` / `quiet` opts that drive the cleaner sweep output.
5
- */
6
-
7
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
11
- import { eq } from 'drizzle-orm';
12
- import { type DbClient, getDb } from '../../db/client';
13
- import { modules } from '../../db/schema';
14
- import { classifyVersionChange, upgradeOne } from './module-upgrade';
15
-
16
- describe('classifyVersionChange', () => {
17
- test('identical versions are up-to-date', () => {
18
- expect(classifyVersionChange('1.0.0', '1.0.0')).toBe('up-to-date');
19
- expect(classifyVersionChange('1.0.0+3', '1.0.0+3')).toBe('up-to-date');
20
- expect(classifyVersionChange('2.4.7+5', '2.4.7+5')).toBe('up-to-date');
21
- });
22
-
23
- test('major bump → breaking', () => {
24
- expect(classifyVersionChange('1.0.0+3', '2.0.0+1')).toBe('major');
25
- expect(classifyVersionChange('1.5.9', '2.0.0')).toBe('major');
26
- // Even a tiny step into the next major counts as breaking; the
27
- // operator decides whether to take it.
28
- expect(classifyVersionChange('1.0.0+9', '2.0.0+0')).toBe('major');
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ type PollCandidate,
4
+ pickAutoUpgrade,
5
+ pickUpgradePolicy,
6
+ selectPollTargets,
7
+ } from './module-upgrade';
8
+
9
+ describe('pickUpgradePolicy (ISS-0138 config override > manifest default > by-semver)', () => {
10
+ test('operator config wins over the manifest default', () => {
11
+ expect(pickUpgradePolicy('always-safe', 'always-fast')).toBe('always-safe');
29
12
  });
30
13
 
31
- test('minor bump non-breaking', () => {
32
- expect(classifyVersionChange('1.0.0+3', '1.1.0+1')).toBe('minor');
33
- expect(classifyVersionChange('2.5.0', '2.6.0')).toBe('minor');
14
+ test('manifest default applies when there is no config override', () => {
15
+ expect(pickUpgradePolicy(undefined, 'always-fast')).toBe('always-fast');
34
16
  });
35
17
 
36
- test('patch bump non-breaking', () => {
37
- expect(classifyVersionChange('1.0.0+3', '1.0.1+1')).toBe('patch');
38
- expect(classifyVersionChange('2.5.7', '2.5.8')).toBe('patch');
18
+ test('defaults to by-semver when neither is set', () => {
19
+ expect(pickUpgradePolicy(undefined, undefined)).toBe('by-semver');
39
20
  });
40
21
 
41
- test('revision-only bump (+N) patch (non-breaking)', () => {
42
- // Exact case the user is hitting today: same code, fresh publish.
43
- expect(classifyVersionChange('1.0.0+3', '1.0.0+4')).toBe('patch');
44
- expect(classifyVersionChange('namecheap-1.0.0', 'namecheap-1.0.0+5')).not.toBe('major');
45
- expect(classifyVersionChange('3.1.0+0', '3.1.0+9')).toBe('patch');
22
+ test('an unknown value falls back to by-semver (no crash on bad input)', () => {
23
+ expect(pickUpgradePolicy('bogus', undefined)).toBe('by-semver');
24
+ expect(pickUpgradePolicy(undefined, 'nonsense')).toBe('by-semver');
46
25
  });
26
+ });
47
27
 
48
- test('installed ahead of registry ahead (skip silently)', () => {
49
- // Operator pushed locally without publishing registry is stale.
50
- expect(classifyVersionChange('2.0.0+1', '1.5.0+9')).toBe('ahead');
51
- expect(classifyVersionChange('1.0.1', '1.0.0')).toBe('ahead');
52
- expect(classifyVersionChange('1.0.0+5', '1.0.0+3')).toBe('ahead');
28
+ describe('pickAutoUpgrade (ISS-0139 opt-in: config override > manifest default > off)', () => {
29
+ test('config override (string or boolean) wins', () => {
30
+ expect(pickAutoUpgrade('true', false)).toBe(true);
31
+ expect(pickAutoUpgrade('false', true)).toBe(false);
32
+ expect(pickAutoUpgrade(true, false)).toBe(true);
53
33
  });
54
34
 
55
- test('tolerates `v` / `=` prefixes', () => {
56
- expect(classifyVersionChange('v1.0.0+3', 'v1.0.0+4')).toBe('patch');
57
- expect(classifyVersionChange('=1.0.0', '=1.1.0')).toBe('minor');
35
+ test('manifest default applies with no override', () => {
36
+ expect(pickAutoUpgrade(undefined, true)).toBe(true);
37
+ expect(pickAutoUpgrade(undefined, false)).toBe(false);
58
38
  });
59
39
 
60
- test('missing patch / revision segments default to 0', () => {
61
- expect(classifyVersionChange('1.0', '1.0.1')).toBe('patch');
62
- expect(classifyVersionChange('1.0', '2.0')).toBe('major');
63
- expect(classifyVersionChange('1.0.0', '1.0.0+1')).toBe('patch');
40
+ test('defaults to OFF (opt-in) when neither is set', () => {
41
+ expect(pickAutoUpgrade(undefined, undefined)).toBe(false);
64
42
  });
65
43
  });
66
44
 
67
- describe('upgradeOnedisplayVersion and quiet', () => {
68
- let tempDir: string;
69
- let srcDir: string;
70
- let installedDir: string;
71
- let db: DbClient;
72
-
73
- beforeEach(() => {
74
- tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-'));
75
- process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
76
- process.env.CELILO_ORIGINAL_CWD = tempDir;
77
-
78
- // Pre-installed module landing zone (where files get copied to).
79
- installedDir = join(tempDir, 'installed', 'testmod');
80
- mkdirSync(installedDir, { recursive: true });
81
-
82
- // "New" source dir we're upgrading from (mimics a registry extract).
83
- srcDir = join(tempDir, 'src');
84
- mkdirSync(srcDir, { recursive: true });
85
- writeFileSync(
86
- join(srcDir, 'manifest.yml'),
87
- `celilo_contract: "1.0"
88
- id: testmod
89
- name: Test Module
90
- version: 1.0.0
91
- description: fixture
92
- `,
93
- );
94
-
95
- db = getDb();
96
- // Seed the modules table with the "currently installed" record.
97
- // We deliberately put a registry-style version string in here so
98
- // displayVersion-less upgrades preserve the previousVersion field.
99
- db.insert(modules)
100
- .values({
101
- id: 'testmod',
102
- name: 'Test Module',
103
- sourcePath: installedDir,
104
- version: '1.0.0+5',
105
- manifestData: {
106
- celilo_contract: '1.0',
107
- id: 'testmod',
108
- name: 'Test Module',
109
- version: '1.0.0',
110
- },
111
- })
112
- .run();
45
+ describe('selectPollTargets (ISS-0139 opted-in + a newer registry version)', () => {
46
+ const base = (over: Partial<PollCandidate>): PollCandidate => ({
47
+ moduleId: 'm',
48
+ installed: '1.0.0',
49
+ latest: '1.0.1',
50
+ autoUpgrade: true,
51
+ ...over,
113
52
  });
114
53
 
115
- afterEach(() => {
116
- rmSync(tempDir, { recursive: true, force: true });
117
- process.env.CELILO_DB_PATH = undefined;
118
- process.env.CELILO_ORIGINAL_CWD = undefined;
54
+ test('selects opted-in modules with a newer version', () => {
55
+ expect(
56
+ selectPollTargets([base({ moduleId: 'caddy', installed: '1.0.0', latest: '1.1.0' })]),
57
+ ).toEqual([{ moduleId: 'caddy', from: '1.0.0', to: '1.1.0' }]);
119
58
  });
120
59
 
121
- test('returns previousVersion + newVersion in the success outcome', async () => {
122
- const result = await upgradeOne(srcDir, db, {}, { quiet: true });
123
- expect(result.status).toBe('success');
124
- if (result.status !== 'success') return; // narrow for ts
125
- expect(result.moduleId).toBe('testmod');
126
- expect(result.previousVersion).toBe('1.0.0+5');
127
- // No displayVersion supplied → falls back to the new manifest's
128
- // semver core (no +N visible to the operator from a path upgrade).
129
- expect(result.newVersion).toBe('1.0.0');
60
+ test('skips modules not opted in', () => {
61
+ expect(selectPollTargets([base({ autoUpgrade: false })])).toEqual([]);
130
62
  });
131
63
 
132
- test('displayVersion is persisted to modules.version (so +N survives)', async () => {
133
- // The bug case: registry says 1.0.0+6, manifest says 1.0.0. Without
134
- // displayVersion, the +6 was dropped on the floor and `module list`
135
- // rolled back to "1.0.0", masking the actual installed revision.
136
- const result = await upgradeOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' });
137
- expect(result.status).toBe('success');
138
- if (result.status !== 'success') return;
139
- expect(result.newVersion).toBe('1.0.0+6');
140
-
141
- const row = db.select().from(modules).all()[0];
142
- expect(row.version).toBe('1.0.0+6');
64
+ test('skips modules already up to date or ahead', () => {
65
+ expect(selectPollTargets([base({ installed: '1.0.1', latest: '1.0.1' })])).toEqual([]);
66
+ expect(selectPollTargets([base({ installed: '1.0.2', latest: '1.0.1' })])).toEqual([]);
143
67
  });
144
68
 
145
- test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
146
- // We can't easily intercept @clack's log output without adding test
147
- // hooks, so instead we exercise that the call simply succeeds and
148
- // returns the structured outcome — the sweep relies on this to
149
- // render its own output without duplicates. A non-quiet call
150
- // exercises the same code path with the log lines enabled; both
151
- // return the same shape.
152
- const quietResult = await upgradeOne(srcDir, db, {}, { quiet: true });
153
- expect(quietResult.status).toBe('success');
154
- if (quietResult.status !== 'success') return;
155
- expect(quietResult.newVersion).toBe('1.0.0');
69
+ test('skips modules absent from the registry (latest=null)', () => {
70
+ expect(selectPollTargets([base({ latest: null })])).toEqual([]);
156
71
  });
157
72
 
158
- // Regression for the namecheap stale-findings problem: the upgrade
159
- // path MUST overwrite manifestData with the new manifest — otherwise
160
- // any subsequent audit (or any code path that reads from the DB)
161
- // sees the OLD manifest's required vars even after the operator
162
- // upgraded to a version that removed them. The user hit this on
163
- // celilo-mgmt: namecheap@3.1.1+6 dropped the `domains` variable, but
164
- // post-upgrade the audit still complained "required config 'domains'
165
- // is not set" because the DB's manifestData hadn't been refreshed.
166
- test('persists new manifest to modules.manifestData (not just .version)', async () => {
167
- // Write a brand-new manifest.yml in srcDir that REMOVES a
168
- // variable the old DB record had. After upgrade, the modules
169
- // row's manifestData should reflect the removal.
170
- writeFileSync(
171
- join(srcDir, 'manifest.yml'),
172
- `celilo_contract: "1.0"
173
- id: testmod
174
- name: Test Module Renamed
175
- version: 2.0.0
176
- description: fixture v2
177
- variables:
178
- owns: []
179
- imports: []
180
- `,
181
- );
182
- // Simulate the DB starting in a state where manifestData has a
183
- // `variables.owns` array — the namecheap-3.1.0 → 3.1.1 case.
184
- db.update(modules)
185
- .set({
186
- manifestData: {
187
- celilo_contract: '1.0',
188
- id: 'testmod',
189
- name: 'Test Module',
190
- version: '1.0.0',
191
- variables: { owns: [{ name: 'domains', type: 'array', required: true }], imports: [] },
192
- },
193
- })
194
- .where(eq(modules.id, 'testmod'))
195
- .run();
196
-
197
- const result = await upgradeOne(srcDir, db, {}, { quiet: true, displayVersion: '2.0.0+1' });
198
- expect(result.status).toBe('success');
199
-
200
- const row = db.select().from(modules).all()[0];
201
- // version field updates (this part already worked):
202
- expect(row.version).toBe('2.0.0+1');
203
- expect(row.name).toBe('Test Module Renamed');
204
- // manifestData reflects the NEW manifest — the dropped variable
205
- // is gone. This is the regression assertion.
206
- const manifest = row.manifestData as {
207
- version: string;
208
- name: string;
209
- variables?: { owns: unknown[] };
210
- };
211
- expect(manifest.version).toBe('2.0.0');
212
- expect(manifest.name).toBe('Test Module Renamed');
213
- expect(manifest.variables?.owns ?? []).toEqual([]);
214
- });
215
-
216
- // Regression for ISS-0091: `module update` used to skip event-bus
217
- // subscription registration (only `module import` did it), so a
218
- // refreshed module silently lost its reconcile subscriptions — found
219
- // live when caddy's reconcile_routes subscription vanished after a
220
- // path-based update.
221
- test('registers the new manifest subscriptions on the bus (ISS-0091)', async () => {
222
- process.env.EVENT_BUS_DB = join(tempDir, 'events.db');
223
- try {
224
- writeFileSync(
225
- join(srcDir, 'manifest.yml'),
226
- `celilo_contract: "1.0"
227
- id: testmod
228
- name: Test Module
229
- version: 1.1.0
230
- description: fixture with subscriptions
231
- subscriptions:
232
- - name: testmod-tick
233
- pattern: timer.tick.15m
234
- handler: "true"
235
- `,
236
- );
237
- const result = await upgradeOne(srcDir, db, {}, { quiet: true });
238
- expect(result.status).toBe('success');
239
-
240
- const { openBus, defineEvents } = await import('@celilo/event-bus');
241
- const bus = openBus({ dbPath: join(tempDir, 'events.db'), events: defineEvents({}) });
242
- try {
243
- // Subscriber names are scoped `<module-id>.<sub-name>` on the bus.
244
- expect(bus.getSubscriberByName('testmod.testmod-tick')).not.toBeNull();
245
- } finally {
246
- bus.close();
247
- }
248
- } finally {
249
- process.env.EVENT_BUS_DB = undefined;
250
- }
73
+ test('picks only the eligible subset from a mixed set', () => {
74
+ const targets = selectPollTargets([
75
+ base({ moduleId: 'a', installed: '1.0.0', latest: '1.0.1', autoUpgrade: true }),
76
+ base({ moduleId: 'b', installed: '1.0.0', latest: '2.0.0', autoUpgrade: false }),
77
+ base({ moduleId: 'c', installed: '1.0.0', latest: null, autoUpgrade: true }),
78
+ base({ moduleId: 'd', installed: '1.0.0', latest: '1.0.0', autoUpgrade: true }),
79
+ ]);
80
+ expect(targets.map((t) => t.moduleId)).toEqual(['a']);
251
81
  });
252
82
  });