@celilo/e2e 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -113,6 +113,14 @@ function handleSend(id: unknown, params: Record<string, unknown>): Response {
113
113
  if (recipients.length === 0) return rpcError(id, -32602, 'No recipient given');
114
114
 
115
115
  for (const recipient of recipients) {
116
+ // The linked account is ALWAYS a valid recipient: messaging yourself is
117
+ // note-to-self, a first-class Signal feature and the default
118
+ // single-operator setup (design R2) — the route points at the very number
119
+ // the transport is a secondary device of. Rejecting it as "unregistered"
120
+ // is something no real daemon does, and it made the note-to-self ack path
121
+ // untestable end to end: the page could not be sent, so no token ever
122
+ // reached the operator to reply with.
123
+ if (recipient === ACCOUNT) continue;
116
124
  // Push back like Signal: an unregistered number is a hard failure, not a
117
125
  // silent no-op.
118
126
  if (KNOWN_RECIPIENTS.size > 0 && !KNOWN_RECIPIENTS.has(recipient)) {
@@ -75,6 +75,56 @@ const activeProjects: Set<string> = new Set();
75
75
  * MUST stay fully synchronous: it runs from an `exit` handler, where the
76
76
  * event loop is already closed and any async work is silently dropped.
77
77
  */
78
+ /**
79
+ * The commands that remove a per-test project WITHOUT needing its compose file.
80
+ * Pure (Rule 10.1) so the no-compose-dependency property is testable.
81
+ *
82
+ * `docker compose -p <project> down` — what this used to run — resolves the
83
+ * project from a compose file in the CURRENT DIRECTORY. An exit handler has no
84
+ * reliable cwd, so compose exited "no configuration file provided", the error
85
+ * was swallowed by the surrounding catch, and nothing was removed. The handler
86
+ * fired correctly and tore down nothing.
87
+ *
88
+ * Removing by name needs only the project name, which we already have.
89
+ * Per-test projects are `celilo-e2e-<timestamp>`; the shared project is
90
+ * `celilo-e2e-shared`, which contains no timestamp and so is never matched —
91
+ * shared infra stays up, as it must.
92
+ */
93
+ export function projectTeardownCommands(project: string): {
94
+ listContainers: string;
95
+ listNetworks: string;
96
+ } {
97
+ return {
98
+ listContainers: `docker ps -aq --filter name=${project}`,
99
+ listNetworks: `docker network ls --format {{.Name}} --filter name=${project}`,
100
+ };
101
+ }
102
+
103
+ /** Force-remove a project's containers and networks by name. Never throws. */
104
+ function forceRemoveProject(project: string): void {
105
+ const cmds = projectTeardownCommands(project);
106
+ try {
107
+ const ids = execSync(cmds.listContainers, { timeout: 15_000, stdio: 'pipe' })
108
+ .toString()
109
+ .split('\n')
110
+ .filter(Boolean);
111
+ if (ids.length > 0) {
112
+ execSync(`docker rm -f ${ids.join(' ')}`, { timeout: 30_000, stdio: 'pipe' });
113
+ }
114
+ } catch {}
115
+ try {
116
+ const nets = execSync(cmds.listNetworks, { timeout: 15_000, stdio: 'pipe' })
117
+ .toString()
118
+ .split('\n')
119
+ .filter(Boolean);
120
+ for (const net of nets) {
121
+ try {
122
+ execSync(`docker network rm ${net}`, { timeout: 10_000, stdio: 'pipe' });
123
+ } catch {}
124
+ }
125
+ } catch {}
126
+ }
127
+
78
128
  function cleanupOnExit() {
79
129
  // `--keep` / `--reuse` mean the operator wants the stack to survive for
80
130
  // debugging — and they want it MOST after a failure, which is exactly the
@@ -82,12 +132,17 @@ function cleanupOnExit() {
82
132
  if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') return;
83
133
 
84
134
  for (const project of activeProjects) {
135
+ // Compose first when it can work — it also drops volumes and orphans — but
136
+ // it is best-effort, so never rely on it having done anything.
85
137
  try {
86
- execSync(`docker compose -p ${project} down --volumes --remove-orphans`, {
138
+ execSync(`docker compose -f ${COMPOSE_FILE} -p ${project} down --volumes --remove-orphans`, {
139
+ cwd: PACKAGE_ROOT,
87
140
  timeout: 30_000,
88
141
  stdio: 'pipe',
89
142
  });
90
143
  } catch {}
144
+ // Authoritative: needs no compose file, no cwd, no working directory state.
145
+ forceRemoveProject(project);
91
146
  }
92
147
  activeProjects.clear();
93
148
  try {
@@ -15,6 +15,7 @@ import { readFileSync } from 'node:fs';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { expect, test } from 'bun:test';
18
+ import { projectTeardownCommands } from './container-manager';
18
19
 
19
20
  const SRC = readFileSync(
20
21
  join(dirname(fileURLToPath(import.meta.url)), 'container-manager.ts'),
@@ -39,6 +40,31 @@ test('cleanup honors --keep / --reuse so a failed run stays debuggable', () => {
39
40
  expect(body).toContain('CELILO_E2E_REUSE');
40
41
  });
41
42
 
43
+ // The first version of this fix wired the handler correctly and still leaked,
44
+ // because the teardown it ran was `docker compose -p <project> down` with no
45
+ // `-f` and no cwd. Compose resolves the project from a compose file in the
46
+ // CURRENT DIRECTORY; an exit handler has no reliable cwd, so it exited "no
47
+ // configuration file provided", the surrounding catch swallowed it, and nothing
48
+ // was removed. Asserting the hook exists was not enough — assert it can work.
49
+ test('teardown does not depend on a compose file being findable', () => {
50
+ const cmds = projectTeardownCommands('celilo-e2e-1785546029645');
51
+ for (const cmd of Object.values(cmds)) {
52
+ expect(cmd).not.toContain('docker compose');
53
+ expect(cmd).toContain('celilo-e2e-1785546029645');
54
+ }
55
+ });
56
+
57
+ test('teardown cannot match the shared project', () => {
58
+ // Per-test projects carry a timestamp; `celilo-e2e-shared` does not, so a
59
+ // name filter for one can never match the other. Shared infra must survive —
60
+ // it is torn down separately, and removing it mid-suite breaks every
61
+ // subsequent test.
62
+ const cmds = projectTeardownCommands('celilo-e2e-1785546029645');
63
+ for (const cmd of Object.values(cmds)) {
64
+ expect(cmd).not.toContain('celilo-e2e-shared');
65
+ }
66
+ });
67
+
42
68
  test("the premise holds: process.exit() fires 'exit' but NOT 'beforeExit'", async () => {
43
69
  // The whole fix rests on this runtime behavior. If it ever changes, the
44
70
  // reasoning above is void and this should fail loudly rather than silently