test: e2e auth baseline tests + webapp testcontainer infrastructure (#3438)
Adds a minimal end-to-end test harness that spawns the compiled webapp as a child process against a throwaway Postgres container, plus a baseline of 8 auth-behaviour tests. These tests will be used as a regression check before and after the upcoming apiBuilder RBAC migration to confirm auth behaviour is unchanged. ## What's included **`internal-packages/testcontainers/src/webapp.ts`** (new) Spawns `build/server.js` with a dynamically allocated port, polls `/healthcheck`, and exposes `WebappInstance` and `startTestServer()` (postgres container + webapp + PrismaClient in one call). Key details: - Uses `process.execPath` so the correct Node binary is found in forked test processes - Sets `NODE_PATH` to `node_modules/.pnpm/node_modules` so pnpm-hoisted transitive deps (e.g. `eventsource-parser`) resolve correctly inside the subprocess - Overrides both `PORT` and `REMIX_APP_PORT` so Vite's automatic `.env` loading doesn't override the dynamically allocated port **`internal-packages/testcontainers/package.json`** Adds `./webapp` sub-path export so tests can `import from "@internal/testcontainers/webapp"`. **`internal-packages/testcontainers/src/index.ts`** Exports `createPostgresContainer` (used internally by `webapp.ts`). **`apps/webapp/test/helpers/seedTestEnvironment.ts`** (new) Creates a minimal org → project → environment row set with random suffixes. **`apps/webapp/test/api-auth.e2e.test.ts`** (new) 8 tests across two suites: - API-key bearer: valid key (auth passes, 404), missing header (401), invalid key (401), error body shape - JWT bearer: valid JWT on JWT-enabled route (passes), valid JWT on non-JWT route (401), empty-scope JWT (403), wrong signing key (401) ## How to run ```bash # Build required first (one-time) pnpm run build --filter webapp cd apps/webapp && pnpm exec vitest run test/api-auth.e2e.test.ts ``` ## Test plan - [x] All 8 tests pass against the current webapp build - [x] Webapp healthcheck returns 200 on startup - [ ] CI passes --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
name: "🧪 E2E Tests: Webapp"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
e2eTests:
|
||||
name: "🧪 E2E Tests: Webapp"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
steps:
|
||||
- name: 🔧 Disable IPv6
|
||||
run: |
|
||||
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
|
||||
sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
|
||||
sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
|
||||
|
||||
- name: 🔧 Configure docker address pool
|
||||
run: |
|
||||
CONFIG='{
|
||||
"default-address-pools" : [
|
||||
{
|
||||
"base" : "172.17.0.0/12",
|
||||
"size" : 20
|
||||
},
|
||||
{
|
||||
"base" : "192.168.0.0/16",
|
||||
"size" : 24
|
||||
}
|
||||
]
|
||||
}'
|
||||
mkdir -p /etc/docker
|
||||
echo "$CONFIG" | sudo tee /etc/docker/daemon.json
|
||||
|
||||
- name: 🔧 Restart docker daemon
|
||||
run: sudo systemctl restart docker
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
- name: 🐳 Login to DockerHub
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: 🐳 Skipping DockerHub login (no secrets available)
|
||||
if: ${{ !env.DOCKERHUB_USERNAME }}
|
||||
run: echo "DockerHub login skipped because secrets are not available."
|
||||
|
||||
- name: 🐳 Pre-pull testcontainer images
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
echo "Pre-pulling Docker images with authenticated session..."
|
||||
docker pull postgres:14
|
||||
docker pull redis:7.2
|
||||
docker pull testcontainers/ryuk:0.11.0
|
||||
echo "Image pre-pull complete"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🏗️ Build Webapp
|
||||
run: pnpm run build --filter webapp
|
||||
|
||||
- name: 🧪 Run Webapp E2E Tests
|
||||
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default
|
||||
env:
|
||||
WEBAPP_TEST_VERBOSE: "1"
|
||||
@@ -10,6 +10,9 @@ jobs:
|
||||
webapp:
|
||||
uses: ./.github/workflows/unit-tests-webapp.yml
|
||||
secrets: inherit
|
||||
e2e-webapp:
|
||||
uses: ./.github/workflows/e2e-webapp.yml
|
||||
secrets: inherit
|
||||
packages:
|
||||
uses: ./.github/workflows/unit-tests-packages.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* E2E auth baseline tests.
|
||||
*
|
||||
* These tests capture current auth behavior before the apiBuilder migration to RBAC.
|
||||
* Run them before and after the migration to verify behavior is identical.
|
||||
*
|
||||
* Requires a pre-built webapp: pnpm run build --filter webapp
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { TestServer } from "@internal/testcontainers/webapp";
|
||||
import { startTestServer } from "@internal/testcontainers/webapp";
|
||||
import { generateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
|
||||
|
||||
vi.setConfig({ testTimeout: 180_000 });
|
||||
|
||||
// Shared across all tests in this file — one postgres container + one webapp instance.
|
||||
let server: TestServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
}, 120_000);
|
||||
|
||||
async function generateTestJWT(
|
||||
environment: { id: string; apiKey: string },
|
||||
options: { scopes?: string[] } = {}
|
||||
): Promise<string> {
|
||||
const scopes = options.scopes ?? ["read:runs"];
|
||||
return generateJWT({
|
||||
secretKey: environment.apiKey,
|
||||
payload: { pub: true, sub: environment.id, scopes },
|
||||
expirationTime: "15m",
|
||||
});
|
||||
}
|
||||
|
||||
describe("API bearer auth — baseline behavior", () => {
|
||||
it("valid API key: auth passes (404 not 401)", async () => {
|
||||
const { apiKey } = await seedTestEnvironment(server.prisma);
|
||||
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
// Auth passed — resource just doesn't exist
|
||||
expect(res.status).not.toBe(401);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("missing Authorization header: 401", async () => {
|
||||
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("invalid API key: 401", async () => {
|
||||
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
|
||||
headers: { Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("401 response has error field", async () => {
|
||||
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT bearer auth — baseline behavior", () => {
|
||||
it("valid JWT on JWT-enabled route: auth passes", async () => {
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
|
||||
|
||||
// /api/v1/runs has allowJWT: true with superScopes: ["read:runs", ...]
|
||||
const res = await server.webapp.fetch("/api/v1/runs", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
// Auth passed — 200 (empty list) or 400 (bad search params), not 401
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it("valid JWT on non-JWT route: 401", async () => {
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
|
||||
|
||||
// /api/v1/runs/$runParam/result does NOT have allowJWT: true
|
||||
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("JWT with empty scopes on JWT-enabled route: 403", async () => {
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
const jwt = await generateTestJWT(environment, { scopes: [] });
|
||||
|
||||
const res = await server.webapp.fetch("/api/v1/runs", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
// Empty scopes → no read:runs permission → 403
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("JWT signed with wrong key: 401", async () => {
|
||||
const { environment } = await seedTestEnvironment(server.prisma);
|
||||
const jwt = await generateJWT({
|
||||
secretKey: "wrong-signing-key-that-does-not-match-environment-key",
|
||||
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
|
||||
});
|
||||
|
||||
const res = await server.webapp.fetch("/api/v1/runs", {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
function randomHex(len = 12): string {
|
||||
return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
|
||||
}
|
||||
|
||||
export async function seedTestEnvironment(prisma: PrismaClient) {
|
||||
const suffix = randomHex(8);
|
||||
const apiKey = `tr_dev_${randomHex(24)}`;
|
||||
const pkApiKey = `pk_dev_${randomHex(24)}`;
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title: `e2e-test-org-${suffix}`,
|
||||
slug: `e2e-org-${suffix}`,
|
||||
v3Enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `e2e-test-project-${suffix}`,
|
||||
slug: `e2e-proj-${suffix}`,
|
||||
externalRef: `proj_${suffix}`,
|
||||
organizationId: organization.id,
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: "dev",
|
||||
type: "DEVELOPMENT",
|
||||
apiKey,
|
||||
pkApiKey,
|
||||
shortcode: suffix.slice(0, 4),
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { organization, project, environment, apiKey };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
exclude: ["test/**/*.e2e.test.ts"],
|
||||
globals: true,
|
||||
pool: "forks",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.e2e.test.ts"],
|
||||
globals: true,
|
||||
pool: "forks",
|
||||
},
|
||||
// @ts-ignore
|
||||
plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })],
|
||||
});
|
||||
@@ -4,6 +4,10 @@
|
||||
"version": "0.0.1",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./webapp": "./src/webapp.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.11.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
|
||||
@@ -18,7 +18,7 @@ import { StartedClickHouseContainer } from "./clickhouse";
|
||||
import { StartedMinIOContainer, type MinIOConnectionConfig } from "./minio";
|
||||
import { ClickHouseClient, createClient } from "@clickhouse/client";
|
||||
|
||||
export { assertNonNullable } from "./utils";
|
||||
export { assertNonNullable, createPostgresContainer } from "./utils";
|
||||
export { logCleanup };
|
||||
export type { MinIOConnectionConfig };
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { spawn } from "child_process";
|
||||
import { createServer } from "net";
|
||||
import { delimiter, resolve } from "path";
|
||||
import { Network } from "testcontainers";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { createPostgresContainer, createRedisContainer } from "./utils";
|
||||
|
||||
const WEBAPP_ROOT = resolve(__dirname, "../../../apps/webapp");
|
||||
// pnpm hoists transitive deps to node_modules/.pnpm/node_modules but does NOT symlink them
|
||||
// to the root node_modules. We need NODE_PATH so the webapp process can find them at runtime.
|
||||
const PNPM_HOISTED_MODULES = resolve(__dirname, "../../../node_modules/.pnpm/node_modules");
|
||||
|
||||
async function findFreePort(): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
const srv = createServer();
|
||||
srv.listen(0, () => {
|
||||
const port = (srv.address() as { port: number }).port;
|
||||
srv.close((err) => (err ? rej(err) : res(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealthcheck(url: string, timeoutMs = 60000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return;
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Webapp did not become healthy at ${url} within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
export interface WebappInstance {
|
||||
baseUrl: string;
|
||||
fetch(path: string, init?: RequestInit): Promise<Response>;
|
||||
}
|
||||
|
||||
export async function startWebapp(
|
||||
databaseUrl: string,
|
||||
redis: { host: string; port: number }
|
||||
): Promise<{
|
||||
instance: WebappInstance;
|
||||
stop: () => Promise<void>;
|
||||
}> {
|
||||
const port = await findFreePort();
|
||||
|
||||
// Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable
|
||||
const existingNodePath = process.env.NODE_PATH;
|
||||
const nodePath = existingNodePath
|
||||
? `${PNPM_HOISTED_MODULES}${delimiter}${existingNodePath}`
|
||||
: PNPM_HOISTED_MODULES;
|
||||
|
||||
const proc = spawn(process.execPath, ["build/server.js"], {
|
||||
cwd: WEBAPP_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: databaseUrl,
|
||||
DIRECT_URL: databaseUrl,
|
||||
PORT: String(port),
|
||||
REMIX_APP_PORT: String(port), // override .env file value (vitest loads .env via Vite)
|
||||
SESSION_SECRET: "test-session-secret-for-e2e-tests",
|
||||
MAGIC_LINK_SECRET: "test-magic-link-secret-32chars!!",
|
||||
ENCRYPTION_KEY: "test-encryption-key-for-e2e!!!!!", // exactly 32 bytes
|
||||
CLICKHOUSE_URL: "http://localhost:19123", // dummy, auth paths never connect
|
||||
DEPLOY_REGISTRY_HOST: "registry.example.com", // dummy, not needed for auth tests
|
||||
ELECTRIC_ORIGIN: "http://localhost:3060",
|
||||
REDIS_HOST: redis.host,
|
||||
REDIS_PORT: String(redis.port),
|
||||
REDIS_TLS_DISABLED: "true", // all *_REDIS_TLS_DISABLED vars default to this; test Redis has no TLS
|
||||
// Disable all background workers. Each worker has its own env var and its own
|
||||
// check idiom ("0" vs "false" vs boolean), so we set all of them explicitly.
|
||||
WORKER_ENABLED: "false", // disables workerQueue.initialize() (checked === "true")
|
||||
RUN_ENGINE_WORKER_ENABLED: "0", // disables run engine workers (checked === "0", default "1")
|
||||
SCHEDULE_WORKER_ENABLED: "0", // disables schedule engine worker (checked === "0")
|
||||
BATCH_QUEUE_WORKER_ENABLED: "false", // disables batch queue consumers (BoolEnv)
|
||||
LEGACY_RUN_ENGINE_WORKER_ENABLED: "0", // disables legacy run engine worker
|
||||
COMMON_WORKER_ENABLED: "0", // disables common worker
|
||||
RUN_ENGINE_TTL_SYSTEM_DISABLED: "true", // disables TTL expiry system (BoolEnv)
|
||||
RUN_ENGINE_TTL_CONSUMERS_DISABLED: "true", // disables TTL consumers (BoolEnv)
|
||||
RUN_REPLICATION_ENABLED: "0",
|
||||
NODE_PATH: nodePath,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const stderr: string[] = [];
|
||||
proc.stderr?.on("data", (d: Buffer) => {
|
||||
const line = d.toString();
|
||||
stderr.push(line);
|
||||
if (process.env.WEBAPP_TEST_VERBOSE) {
|
||||
process.stderr.write(line);
|
||||
}
|
||||
});
|
||||
|
||||
const stdout: string[] = [];
|
||||
proc.stdout?.on("data", (d: Buffer) => {
|
||||
const line = d.toString();
|
||||
stdout.push(line);
|
||||
if (process.env.WEBAPP_TEST_VERBOSE) {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
});
|
||||
|
||||
// Capture spawn errors instead of throwing from inside the listener.
|
||||
// A synchronous throw from an EventEmitter listener bypasses the surrounding
|
||||
// try/catch and surfaces as an uncaughtException, tearing down the test runner.
|
||||
let spawnError: Error | undefined;
|
||||
proc.on("error", (err) => {
|
||||
spawnError = err;
|
||||
});
|
||||
|
||||
const baseUrl = `http://localhost:${port}`;
|
||||
|
||||
try {
|
||||
if (spawnError) throw spawnError;
|
||||
await waitForHealthcheck(`${baseUrl}/healthcheck`);
|
||||
if (spawnError) throw spawnError;
|
||||
} catch (err) {
|
||||
proc.kill("SIGTERM");
|
||||
const output = [...stdout, ...stderr].join("\n");
|
||||
throw new Error(`Webapp failed to start.\nOutput:\n${output}\n\nOriginal error: ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
instance: {
|
||||
baseUrl,
|
||||
fetch: (path: string, init?: RequestInit) => fetch(`${baseUrl}${path}`, init),
|
||||
},
|
||||
stop: () =>
|
||||
new Promise<void>((res) => {
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
res();
|
||||
}, 10_000);
|
||||
proc.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
res();
|
||||
});
|
||||
proc.kill("SIGTERM");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
webapp: WebappInstance;
|
||||
prisma: PrismaClient;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Convenience helper: starts a postgres + redis container + webapp and returns both for testing. */
|
||||
export async function startTestServer(): Promise<TestServer> {
|
||||
const network = await new Network().start();
|
||||
|
||||
// Track each resource as we acquire it so we can tear it down if a later step fails.
|
||||
let pgContainer: Awaited<ReturnType<typeof createPostgresContainer>>["container"] | undefined;
|
||||
let redisContainer: Awaited<ReturnType<typeof createRedisContainer>>["container"] | undefined;
|
||||
let prisma: PrismaClient | undefined;
|
||||
let stopWebapp: (() => Promise<void>) | undefined;
|
||||
let webapp: WebappInstance;
|
||||
|
||||
try {
|
||||
const pg = await createPostgresContainer(network);
|
||||
pgContainer = pg.container;
|
||||
|
||||
const { container: rc } = await createRedisContainer({ network });
|
||||
redisContainer = rc;
|
||||
|
||||
prisma = new PrismaClient({ datasources: { db: { url: pg.url } } });
|
||||
await prisma.$connect(); // pre-warm pool; surface connection failures before tests start
|
||||
const started = await startWebapp(pg.url, { host: rc.getHost(), port: rc.getPort() });
|
||||
webapp = started.instance;
|
||||
stopWebapp = started.stop;
|
||||
} catch (err) {
|
||||
await stopWebapp?.().catch(() => {});
|
||||
await prisma?.$disconnect().catch(() => {});
|
||||
await pgContainer?.stop().catch(() => {});
|
||||
await redisContainer?.stop().catch(() => {});
|
||||
await network.stop().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
await stopWebapp!().catch((err) => console.error("stopWebapp failed:", err));
|
||||
await prisma!.$disconnect().catch((err) => console.error("prisma.$disconnect failed:", err));
|
||||
await pgContainer!.stop().catch((err) => console.error("pgContainer.stop failed:", err));
|
||||
await redisContainer!.stop().catch((err) => console.error("redisContainer.stop failed:", err));
|
||||
await network.stop().catch((err) => console.error("network.stop failed:", err));
|
||||
};
|
||||
|
||||
return { webapp, prisma: prisma!, stop };
|
||||
}
|
||||
Reference in New Issue
Block a user