Skip to content

Commit 272a60d

Browse files
committed
Enhance testing setup with Docker integration and Vitest configuration
- Added testing instructions to README.md for Docker usage. - Updated package.json to include new test and coverage scripts for containerized testing. - Modified test files to utilize environment variables for API host configuration. - Introduced vite-env.d.ts to define environment variables for TypeScript. - Created run_test_container.mjs to manage test execution in Docker. - Updated Dockerfile.test and docker-compose.yml for test service configuration. - Added entrypoint script for test container setup. - Refactored CI scripts to use Docker for backend services.
1 parent a6eae79 commit 272a60d

File tree

14 files changed

+402
-10
lines changed

14 files changed

+402
-10
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,25 @@ running:
150150
docker compose -f docker-compose.build.yml build
151151
```
152152

153+
## Testing (Docker)
154+
155+
Frontend tests run in Vitest browser mode (Playwright). To keep the environment consistent, use the dedicated test runner compose file instead of the dev container.
156+
157+
Prereqs:
158+
- Ensure `./build-secrets/.npmrc` exists (same requirement as Docker builds).
159+
160+
Run tests:
161+
162+
```bash
163+
yarn test:container
164+
```
165+
166+
Run coverage:
167+
168+
```bash
169+
yarn coverage:container
170+
```
171+
153172
## pre-commit
154173

155174
[Pre-commit](https://pre-commit.com/) enforces code style practices in this

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"plop": "plop",
1313
"test": "vitest run",
1414
"test:watch": "vitest watch",
15-
"coverage": "vitest run --coverage"
15+
"coverage": "vitest run --coverage",
16+
"test:container": "node tools/scripts/run_test_container.mjs test",
17+
"coverage:container": "node tools/scripts/run_test_container.mjs coverage"
1618
},
1719
"engines": {
1820
"node": ">=20.17.0"

packages/dapper-ts/src/__tests__/index.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ let dapper: DapperTs;
99

1010
beforeAll(() => {
1111
dapper = new DapperTs(() => {
12-
return { apiHost: "http://127.0.0.1:8000" };
12+
return {
13+
apiHost:
14+
import.meta.env.VITE_THUNDERSTORE_TEST_API_HOST ??
15+
"http://127.0.0.1:8000",
16+
};
1317
});
1418
});
1519

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
interface ImportMeta {
2+
readonly env: {
3+
readonly VITE_THUNDERSTORE_TEST_API_HOST?: string;
4+
readonly [key: string]: string | undefined;
5+
};
6+
}

packages/dapper-ts/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"outDir": "./dist",
2424
"rootDir": "./src",
2525
"jsx": "react-jsx",
26-
"types": ["@vitest/browser/providers/playwright"]
26+
"types": ["vite/client", "@vitest/browser/providers/playwright"]
2727
},
2828
"include": ["./src/**/*.tsx", "./src/**/*.ts"],
2929
"exclude": ["node_modules"]

packages/thunderstore-api/src/__tests__/defaultConfig.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export const config = () => {
22
return {
3-
apiHost: "http://127.0.0.1:8000",
3+
apiHost:
4+
import.meta.env.VITE_THUNDERSTORE_TEST_API_HOST ??
5+
"http://127.0.0.1:8000",
46
};
57
};
68

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
interface ImportMeta {
2+
readonly env: {
3+
readonly VITE_THUNDERSTORE_TEST_API_HOST?: string;
4+
readonly [key: string]: string | undefined;
5+
};
6+
}

packages/thunderstore-api/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"outDir": "./dist",
2424
"rootDir": "./src",
2525
"jsx": "react-jsx",
26-
"types": ["@vitest/browser/providers/playwright"]
26+
"types": ["vite/client", "@vitest/browser/providers/playwright"]
2727
},
2828
"include": ["./src/**/*.tsx", "./src/**/*.ts"],
2929
"exclude": ["node_modules"]
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { spawn } from "node:child_process";
2+
import process from "node:process";
3+
4+
function usage(exitCode = 1) {
5+
// Keep this short: it shows up in CI logs.
6+
console.error(
7+
"Usage: node tools/scripts/run_test_container.mjs <test|coverage> [-- <vitest args...>]"
8+
);
9+
process.exit(exitCode);
10+
}
11+
12+
function spawnLogged(command, args, options = {}) {
13+
const printable = [command, ...args].join(" ");
14+
console.log(printable);
15+
16+
return new Promise((resolve, reject) => {
17+
const child = spawn(command, args, {
18+
stdio: "inherit",
19+
shell: false,
20+
...options,
21+
});
22+
23+
child.on("error", reject);
24+
child.on("close", (code) => {
25+
if (code === 0) {
26+
resolve();
27+
} else {
28+
const err = new Error(`${printable} failed with exit code ${code}`);
29+
err.exitCode = code;
30+
reject(err);
31+
}
32+
});
33+
});
34+
}
35+
36+
async function waitForUrl(url, { timeoutMs = 120_000, pollMs = 1_000 } = {}) {
37+
const deadline = Date.now() + timeoutMs;
38+
39+
// Node 24 has global fetch.
40+
while (Date.now() < deadline) {
41+
try {
42+
const abortController = new AbortController();
43+
const timeout = setTimeout(() => abortController.abort(), 3000);
44+
const response = await fetch(url, {
45+
signal: abortController.signal,
46+
// The backend may redirect to host.docker.internal (needed for browser-in-container),
47+
// but that hostname isn't guaranteed to resolve on the host. Don't follow redirects.
48+
redirect: "manual",
49+
});
50+
clearTimeout(timeout);
51+
52+
// Treat any successful/redirect response as "up".
53+
if (response.status >= 200 && response.status < 400) {
54+
return;
55+
}
56+
} catch {
57+
// ignore until timeout
58+
}
59+
60+
const remainingSeconds = Math.max(
61+
0,
62+
Math.round((deadline - Date.now()) / 1000)
63+
);
64+
console.log(`Waiting for ${url} (${remainingSeconds}s remaining)`);
65+
await new Promise((r) => setTimeout(r, pollMs));
66+
}
67+
68+
throw new Error(`Timed out waiting for ${url}`);
69+
}
70+
71+
async function waitForDjangoInContainer(
72+
composeFile,
73+
{ timeoutMs = 120_000, pollMs = 1_000 } = {}
74+
) {
75+
const deadline = Date.now() + timeoutMs;
76+
77+
const pythonCheck =
78+
"import http.client, sys; " +
79+
"c=http.client.HTTPConnection('127.0.0.1',8000,timeout=2); " +
80+
"c.request('GET','/'); " +
81+
"r=c.getresponse(); " +
82+
"sys.exit(0 if 200 <= r.status < 400 else 1)";
83+
84+
while (Date.now() < deadline) {
85+
try {
86+
await spawnLogged(
87+
"docker",
88+
[
89+
"compose",
90+
"-f",
91+
composeFile,
92+
"exec",
93+
"-T",
94+
"django",
95+
"python",
96+
"-c",
97+
pythonCheck,
98+
],
99+
{
100+
env: process.env,
101+
stdio: "ignore",
102+
}
103+
);
104+
return;
105+
} catch {
106+
// ignore until timeout
107+
}
108+
109+
const remainingSeconds = Math.max(
110+
0,
111+
Math.round((deadline - Date.now()) / 1000)
112+
);
113+
console.log(
114+
`Waiting for django to be ready inside container (${remainingSeconds}s remaining)`
115+
);
116+
await new Promise((r) => setTimeout(r, pollMs));
117+
}
118+
119+
throw new Error("Timed out waiting for django to be ready inside container");
120+
}
121+
122+
function parseArgs(argv) {
123+
const [subcommand, ...rest] = argv;
124+
if (!subcommand || subcommand === "-h" || subcommand === "--help") {
125+
usage(0);
126+
}
127+
128+
if (subcommand !== "test" && subcommand !== "coverage") {
129+
console.error(`Unknown subcommand: ${subcommand}`);
130+
usage(1);
131+
}
132+
133+
const dashDashIndex = rest.indexOf("--");
134+
// Yarn v1 forwards args without requiring `--`. Accept both forms:
135+
// yarn test:container path/to/test
136+
// yarn test:container -- path/to/test
137+
const vitestArgs =
138+
dashDashIndex === -1 ? rest : rest.slice(dashDashIndex + 1);
139+
140+
return { subcommand, vitestArgs };
141+
}
142+
143+
async function main() {
144+
const { subcommand, vitestArgs } = parseArgs(process.argv.slice(2));
145+
146+
const backendComposeFile =
147+
"tools/thunderstore-test-backend/docker-compose.yml";
148+
149+
try {
150+
// Start backend in the background.
151+
await spawnLogged(
152+
"docker",
153+
[
154+
"compose",
155+
"-f",
156+
backendComposeFile,
157+
"up",
158+
"-d",
159+
"--remove-orphans",
160+
"--build",
161+
// Only start backend dependencies; the same compose file also contains
162+
// the test runner services.
163+
"db",
164+
"redis",
165+
"rabbitmq",
166+
"minio",
167+
"django",
168+
],
169+
{ env: process.env }
170+
);
171+
172+
// Wait until it's actually serving traffic (cold starts can take a while).
173+
// We probe from inside the container network so we don't depend on any
174+
// published host port and we implicitly wait for the setup commands in
175+
// run_test_backend.py to finish.
176+
await waitForDjangoInContainer(backendComposeFile, { timeoutMs: 300_000 });
177+
178+
const command = subcommand === "test" ? "test" : "coverage";
179+
180+
const args = [
181+
"compose",
182+
"-f",
183+
backendComposeFile,
184+
"run",
185+
"--rm",
186+
"--build",
187+
"cyberstorm-tests",
188+
"yarn",
189+
command,
190+
...vitestArgs,
191+
];
192+
193+
await spawnLogged("docker", args, { env: process.env });
194+
} finally {
195+
// Always tear down the test backend so it doesn't conflict with dev.
196+
await spawnLogged(
197+
"docker",
198+
["compose", "-f", backendComposeFile, "down", "--remove-orphans"],
199+
{ env: process.env }
200+
).catch(() => {
201+
// Best-effort cleanup.
202+
});
203+
}
204+
}
205+
206+
main().catch((err) => {
207+
console.error(err?.stack || String(err));
208+
process.exit(typeof err?.exitCode === "number" ? err.exitCode : 1);
209+
});

tools/test-ci/run_ci_script.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,26 @@ def poll_backend() -> bool:
112112

113113

114114
def start_backend() -> bool:
115-
run_command("docker compose up -d", cwd=BACKEND_DIR)
115+
run_command(
116+
[
117+
"docker",
118+
"compose",
119+
"up",
120+
"-d",
121+
"--remove-orphans",
122+
"db",
123+
"redis",
124+
"rabbitmq",
125+
"minio",
126+
"django",
127+
],
128+
cwd=BACKEND_DIR,
129+
)
116130
return wait_for_url("http://127.0.0.1:8000/")
117131

118132

119133
def stop_backend():
120-
run_command("docker compose down", cwd=BACKEND_DIR)
134+
run_command(["docker", "compose", "down", "--remove-orphans"], cwd=BACKEND_DIR)
121135

122136

123137
def run_tests():

0 commit comments

Comments
 (0)