Skip to content

Commit 6da58bf

Browse files
d-csclaude
andcommitted
test(webapp): stop CI-fatal env-Redis dials in unit tests — force lazyConnect + stub runtime Redis singletons
The setup-file mocks of the six eager worker/engine singletons were not enough: CI shards still flooded ECONNREFUSED/maxRetries. Two further classes of env-Redis usage survived them, reproduced locally by running the failing shards with REDIS_PORT pointed at a dead port: 1. Import-time construction: ~15 more singletons (platform cache, billing-limit reconcile queue, alerts rate limiter, DevPresence, auto-increment counter, s2 token cache, v1 streams cache, ...) build ioredis clients at module import, and ioredis dials on construction. A global ioredis mock now forces lazyConnect: true so clients only dial on first command — testcontainer-backed tests are unaffected (their first command connects as before). 2. Runtime commands inside code under test: tracePubSub.publish() (eventRepository writes), alertsRateLimiter.check() (deliverAlert) and the task metadata cache each issue commands against env-configured Redis mid-test; every command burns ~20 reconnect cycles before its error surfaces, which times the tests out. These three modules are now stubbed (metadata cache pinned to its Noop implementation, which is what CI's unset env resolves to anyway). Verified: webapp shards 2/5/6/8 (the ones failing on the pr06+ stack) run green with Redis pointed at a dead port, and shards 2/8 stay green against live Redis (store-routing suites still exercise the real run store). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d5415e8 commit 6da58bf

1 file changed

Lines changed: 85 additions & 0 deletions

File tree

apps/webapp/test/setup.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,91 @@ const noopProxy = () =>
4242
}
4343
);
4444

45+
// Beyond the modules mocked above, dozens more app modules construct an
46+
// ioredis client at import time pointed at env-configured Redis, and ioredis
47+
// dials on construction — in CI (no Redis service) that floods ECONNREFUSED at
48+
// shard scale. Force `lazyConnect: true` on every client instead: import-time
49+
// singletons construct but never dial, while anything that actually issues a
50+
// command (tests against live testcontainers) connects on first command
51+
// exactly as before.
52+
vi.mock("ioredis", async (importOriginal) => {
53+
const actual = await importOriginal<typeof import("ioredis")>();
54+
55+
// Normalize ioredis's overloaded ctor args — (), (port), (path),
56+
// (port, host), (opts), (port, opts), (port, host, opts), (path, opts) —
57+
// so lazyConnect lands in the options object in every form.
58+
function withLazyConnect(args: unknown[]): unknown[] {
59+
if (args.length === 0) {
60+
return [{ lazyConnect: true }];
61+
}
62+
const last = args[args.length - 1];
63+
if (typeof last === "object" && last !== null) {
64+
return [...args.slice(0, -1), { ...last, lazyConnect: true }];
65+
}
66+
return [...args, { lazyConnect: true }];
67+
}
68+
69+
class LazyRedis extends actual.Redis {
70+
constructor(...args: unknown[]) {
71+
// @ts-expect-error – forwarding ioredis's overloaded ctor args
72+
super(...withLazyConnect(args));
73+
}
74+
}
75+
76+
class LazyCluster extends actual.Cluster {
77+
constructor(startupNodes: unknown, options?: Record<string, unknown>) {
78+
// @ts-expect-error – forwarding ioredis's ctor args
79+
super(startupNodes, { ...options, lazyConnect: true });
80+
}
81+
}
82+
83+
// Keep the `Redis.Cluster` static alias (`new Redis.Cluster(...)`) working.
84+
// The base class exposes `Cluster` as a getter-only static, so define our
85+
// own property rather than assigning through the inherited getter.
86+
Object.defineProperty(LazyRedis, "Cluster", { value: LazyCluster });
87+
88+
return {
89+
...actual,
90+
default: LazyRedis,
91+
Redis: LazyRedis,
92+
Cluster: LazyCluster,
93+
};
94+
});
95+
96+
// alertsRateLimiter.check() is invoked at runtime by deliverAlert; against
97+
// env-configured Redis each check burns ~20 reconnect cycles before its
98+
// caught error, stalling alert-path tests into timeouts. Allow everything.
99+
vi.mock("~/v3/alertsRateLimiter.server", () => ({
100+
alertsRateLimiter: { check: vi.fn().mockResolvedValue({ allowed: true }) },
101+
}));
102+
103+
// tracePubSub.publish() runs inside eventRepository writes; each publish to
104+
// env-configured Redis stalls ~20 reconnect cycles (errors are allSettled-
105+
// swallowed but awaited), timing out any test that records trace events.
106+
vi.mock("~/v3/services/tracePubSub.server", async () => {
107+
const { EventEmitter } = await import("node:events");
108+
return {
109+
tracePubSub: {
110+
publish: vi.fn().mockResolvedValue(undefined),
111+
subscribeToTrace: vi.fn().mockResolvedValue({
112+
unsubscribe: vi.fn().mockResolvedValue(undefined),
113+
eventEmitter: new EventEmitter(),
114+
}),
115+
},
116+
TracePubSub: class {},
117+
};
118+
});
119+
120+
// Same runtime-stall shape for the task metadata cache (queues concern). CI
121+
// leaves TASK_META_CACHE_REDIS_HOST unset and gets the Noop implementation;
122+
// pin the Noop cache here so env-configured local runs behave identically.
123+
vi.mock("~/services/taskMetadataCacheInstance.server", async () => {
124+
const { NoopTaskMetadataCache } = await vi.importActual<
125+
typeof import("~/services/taskMetadataCache.server")
126+
>("~/services/taskMetadataCache.server");
127+
return { taskMetadataCacheInstance: new NoopTaskMetadataCache() };
128+
});
129+
45130
vi.mock("~/v3/runEngine.server", () => ({ engine: noopProxy() }));
46131
vi.mock("~/v3/marqs/index.server", () => ({ marqs: noopProxy(), MarQS: class {} }));
47132
vi.mock("~/v3/marqs/devPubSub.server", () => ({ devPubSub: noopProxy() }));

0 commit comments

Comments
 (0)