Skip to content

Commit ea93d27

Browse files
committed
test(cli): measure the re-seed window after an install-local uninstall
A third order of events in the uninstall pin: hot install, DELETE, then a hot install of another package, then restart. The DELETE leaves the package registered until the restart, and the second install's metadata:reloaded re-runs plugin-security's declared-permission seeding over it, so the uninstalled package's set is re-projected as a fresh managed_by: package row and survives the restart as an orphan. Its grant stays revoked; that half is a plain assertion. The two set readings are it.fails, measured red and reported for filing, not fixed here. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 47a5b2b commit ea93d27

1 file changed

Lines changed: 93 additions & 2 deletions

File tree

‎packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts‎

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
* before the uninstall, so "no binding" is read off a row that existed, not off
3131
* an empty table.
3232
*
33+
* A third order of events measures the re-seed window — DELETE, then a hot
34+
* install of ANOTHER package, then restart — and records, as `it.fails`, the
35+
* defect it found there; the block above that `describe` says what it is.
36+
*
3337
* ## Spawn shape
3438
*
3539
* Shared with `package-install-local-boot-steps.integration.test.ts` (#21322):
@@ -84,6 +88,22 @@ const ARTIFACT = {
8488
}],
8589
};
8690

91+
/**
92+
* A second, unrelated package for the re-seed order of events: its hot install
93+
* announces `metadata:reloaded`, which re-runs plugin-security's
94+
* declared-permission seeding over every package the running kernel still holds.
95+
*/
96+
const OTHER_APP_ID = 'com.example.notesapp';
97+
const OTHER_ARTIFACT = {
98+
manifest: { id: OTHER_APP_ID, namespace: 'notes_app', version: '0.1.0', type: 'app', name: 'Notes App' },
99+
objects: [{
100+
name: 'notes_app_note',
101+
label: 'Note',
102+
sharingModel: 'public_read_write',
103+
fields: { name: { type: 'text', label: 'Name' } },
104+
}],
105+
};
106+
87107
const groups: ChildProcess[] = [];
88108
const dirs: string[] = [];
89109

@@ -245,6 +265,10 @@ interface Run {
245265
restarted?: Grants;
246266
/** The package's object after the restart — the uninstall's own effect, as the control. */
247267
object?: Answer;
268+
/** Re-seed order only: the hot install of {@link OTHER_APP_ID} after the DELETE. */
269+
otherInstall?: { exit: number | null; output: string };
270+
/** Re-seed order only: same process, right after that second install. */
271+
afterOtherInstall?: Grants;
248272
}
249273

250274
/**
@@ -274,14 +298,17 @@ async function readAfterRestart(live: LiveStart, run: Run): Promise<void> {
274298
run.object = await http(live, 'GET', `/api/v1/data/${TASK}`, token);
275299
}
276300

277-
const runs: Record<'hot' | 'restarted', Run> = { hot: {}, restarted: {} };
301+
const runs: Record<'hot' | 'restarted' | 'reseed', Run> = { hot: {}, restarted: {}, reseed: {} };
278302

279303
beforeAll(async () => {
280304
const root = mkdtempSync(join(tmpdir(), 'install-local-uninstall-'));
281305
dirs.push(root);
282306
const appDir = join(root, 'app');
283307
mkdirSync(join(appDir, 'dist'), { recursive: true });
284308
writeFileSync(join(appDir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT, null, 2), 'utf8');
309+
const otherAppDir = join(root, 'other-app');
310+
mkdirSync(join(otherAppDir, 'dist'), { recursive: true });
311+
writeFileSync(join(otherAppDir, 'dist', 'objectstack.json'), JSON.stringify(OTHER_ARTIFACT, null, 2), 'utf8');
285312
const port = randomPort();
286313

287314
// ── order 1: hot install → DELETE → restart ────────────────────────────
@@ -313,7 +340,24 @@ beforeAll(async () => {
313340
const e = await bootStart(coldDir, coldHome, port);
314341
await readAfterRestart(e, runs.restarted);
315342
await stopGroup(e.child);
316-
}, 6 * BOOT_TIMEOUT_MS);
343+
344+
// ── order 3: hot install → DELETE → hot install of ANOTHER package → restart ──
345+
// The DELETE does not withdraw the package from the running kernel, so it is
346+
// still registered when the second install announces `metadata:reloaded`.
347+
const reseedDir = join(root, 'reseed');
348+
mkdirSync(reseedDir, { recursive: true });
349+
const reseedHome = join(reseedDir, 'home');
350+
const f = await bootStart(reseedDir, reseedHome, port);
351+
const fSession = await authenticate(f);
352+
runs.reseed.install = await packageInstall(appDir, f);
353+
await grantThenUninstall(f, fSession, runs.reseed);
354+
runs.reseed.otherInstall = await packageInstall(otherAppDir, f);
355+
runs.reseed.afterOtherInstall = await readGrants(f, fSession.token, runs.reseed.setId!);
356+
await stopGroup(f.child);
357+
const g = await bootStart(reseedDir, reseedHome, port);
358+
await readAfterRestart(g, runs.reseed);
359+
await stopGroup(g.child);
360+
}, 8 * BOOT_TIMEOUT_MS);
317361

318362
afterAll(async () => {
319363
for (const child of groups) await stopGroup(child);
@@ -358,4 +402,51 @@ describe('#21490: an install-local uninstall runs the registered uninstall clean
358402
});
359403
});
360404
}
405+
406+
// ── The re-seed window: MEASURED RED, reported for filing, not fixed here ──
407+
//
408+
// This DELETE leaves the package registered in the running kernel until the
409+
// next restart (the response's own note says so), and plugin-security's
410+
// `metadata:reloaded` subscriber re-runs the declared-permission seeding over
411+
// every package the kernel holds. So another package's hot install before
412+
// that restart re-projects the uninstalled package's set as a fresh
413+
// `managed_by: package` row, and the restart leaves it orphaned: the package
414+
// is gone, its set is not. The grant does NOT come back — the cleanup deleted
415+
// the binding and the seeding writes none — and that half is pinned plainly.
416+
//
417+
// The two set readings are `it.fails`: each turns red the day its half is
418+
// fixed, which is the cue to promote it to a plain assertion.
419+
describe('hot install → DELETE → hot install of another package → restart (the re-seed window)', () => {
420+
it('precondition: both installs landed, and the DELETE revoked the set and its grant', () => {
421+
const run = runs.reseed;
422+
expect(run.install?.exit, run.install?.output).toBe(0);
423+
expect(run.otherInstall?.exit, run.otherInstall?.output).toBe(0);
424+
expect(run.granted?.status, JSON.stringify(run.granted?.body)).toBe(201);
425+
expect(rowsOf(run.before!.sets).map((r) => [r?.name, r?.managed_by, r?.package_id]))
426+
.toEqual([[PERMISSION_SET, 'package', APP_ID]]);
427+
expect(run.uninstall?.status, JSON.stringify(run.uninstall?.body)).toBe(200);
428+
expect(rowsOf(run.after!.sets), JSON.stringify(run.after!.sets.body)).toEqual([]);
429+
expect(rowsOf(run.after!.bindings), JSON.stringify(run.after!.bindings.body)).toEqual([]);
430+
});
431+
432+
it('the grant stays revoked through the other install and the restart, and the package object is gone', () => {
433+
const run = runs.reseed;
434+
for (const answer of [run.afterOtherInstall!.sets, run.afterOtherInstall!.bindings, run.restarted!.sets, run.restarted!.bindings]) {
435+
expect(answer.status, JSON.stringify(answer.body)).toBe(200);
436+
}
437+
expect(rowsOf(run.afterOtherInstall!.bindings), JSON.stringify(run.afterOtherInstall!.bindings.body)).toEqual([]);
438+
expect(rowsOf(run.restarted!.bindings), JSON.stringify(run.restarted!.bindings.body)).toEqual([]);
439+
expect(run.object?.status, JSON.stringify(run.object?.body)).toBe(404);
440+
});
441+
442+
it.fails('KNOWN-BROKEN: the other package\'s hot install re-projects the uninstalled package\'s set (promote to a plain assertion once fixed)', () => {
443+
const run = runs.reseed;
444+
expect(rowsOf(run.afterOtherInstall!.sets), JSON.stringify(run.afterOtherInstall!.sets.body)).toEqual([]);
445+
});
446+
447+
it.fails('KNOWN-BROKEN: that re-projected set survives the restart as an orphan row (promote to a plain assertion once fixed)', () => {
448+
const run = runs.reseed;
449+
expect(rowsOf(run.restarted!.sets), JSON.stringify(run.restarted!.sets.body)).toEqual([]);
450+
});
451+
});
361452
});

0 commit comments

Comments
 (0)