@elench/testkit 0.1.145 → 0.1.146
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/lib/cli/operations/db/schema/refresh/operation.mjs +11 -1
- package/lib/cli/operations/db/schema/verify/operation.mjs +11 -1
- package/lib/database/admin.mjs +227 -0
- package/lib/database/cleanup.mjs +201 -0
- package/lib/database/constants.mjs +10 -0
- package/lib/database/index.mjs +46 -720
- package/lib/database/local-postgres.mjs +158 -0
- package/lib/database/locks.mjs +31 -0
- package/lib/database/resource-postgres.mjs +72 -0
- package/lib/database/state-files.mjs +53 -0
- package/lib/ownership/docker.mjs +9 -0
- package/lib/runner/lifecycle.mjs +1 -1
- package/lib/runner/maintenance.mjs +3 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { execa } from "execa";
|
|
4
|
+
import {
|
|
5
|
+
LOCAL_ADMIN_DB,
|
|
6
|
+
LOCAL_IMAGE,
|
|
7
|
+
LOCAL_PASSWORD,
|
|
8
|
+
LOCAL_POLL_INTERVAL_MS,
|
|
9
|
+
LOCAL_READY_TIMEOUT_MS,
|
|
10
|
+
LOCAL_USER,
|
|
11
|
+
} from "./constants.mjs";
|
|
12
|
+
import { buildContainerName } from "./naming.mjs";
|
|
13
|
+
import { getLocalInfraDir, readStateValue } from "./state.mjs";
|
|
14
|
+
import { buildDockerResourceLabels, dockerLabelArgs } from "../ownership/docker.mjs";
|
|
15
|
+
import { writeLocalInfraState } from "./state-files.mjs";
|
|
16
|
+
|
|
17
|
+
export async function ensureLocalContainer(productDir, database = {}) {
|
|
18
|
+
const infraDir = getLocalInfraDir(productDir);
|
|
19
|
+
fs.mkdirSync(infraDir, { recursive: true });
|
|
20
|
+
|
|
21
|
+
const containerName =
|
|
22
|
+
readStateValue(path.join(infraDir, "container_name")) || buildContainerName(productDir);
|
|
23
|
+
const image = database.image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE;
|
|
24
|
+
const user = database.user || readStateValue(path.join(infraDir, "user")) || LOCAL_USER;
|
|
25
|
+
const password =
|
|
26
|
+
database.password || readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD;
|
|
27
|
+
|
|
28
|
+
let inspect = await inspectContainer(containerName);
|
|
29
|
+
if (inspect && inspect.Config?.Image !== image) {
|
|
30
|
+
await stopAndRemoveContainer(containerName);
|
|
31
|
+
inspect = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!inspect) {
|
|
35
|
+
const labels = buildLocalPostgresContainerLabels(productDir, containerName);
|
|
36
|
+
await execa("docker", [
|
|
37
|
+
"run",
|
|
38
|
+
"-d",
|
|
39
|
+
"--name",
|
|
40
|
+
containerName,
|
|
41
|
+
...dockerLabelArgs(labels),
|
|
42
|
+
"-e",
|
|
43
|
+
`POSTGRES_USER=${user}`,
|
|
44
|
+
"-e",
|
|
45
|
+
`POSTGRES_PASSWORD=${password}`,
|
|
46
|
+
"-e",
|
|
47
|
+
`POSTGRES_DB=${LOCAL_ADMIN_DB}`,
|
|
48
|
+
"-p",
|
|
49
|
+
"127.0.0.1::5432",
|
|
50
|
+
image,
|
|
51
|
+
]);
|
|
52
|
+
inspect = await inspectContainer(containerName);
|
|
53
|
+
} else if (!inspect.State?.Running) {
|
|
54
|
+
await execa("docker", ["start", containerName]);
|
|
55
|
+
inspect = await inspectContainer(containerName);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hostPort = inspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
|
|
59
|
+
if (!hostPort) {
|
|
60
|
+
throw new Error(`Could not determine published port for local database container ${containerName}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const infra = {
|
|
64
|
+
containerName,
|
|
65
|
+
containerId: inspect.Id,
|
|
66
|
+
image,
|
|
67
|
+
user,
|
|
68
|
+
password,
|
|
69
|
+
host: "127.0.0.1",
|
|
70
|
+
port: Number(hostPort),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await waitForLocalContainerReady(infra);
|
|
74
|
+
writeLocalInfraState(infraDir, infra);
|
|
75
|
+
return infra;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function loadExistingLocalContainer(productDir) {
|
|
79
|
+
const infraDir = getLocalInfraDir(productDir);
|
|
80
|
+
const containerName = readStateValue(path.join(infraDir, "container_name"));
|
|
81
|
+
if (!containerName) return null;
|
|
82
|
+
|
|
83
|
+
const inspect = await inspectContainer(containerName);
|
|
84
|
+
if (!inspect) return null;
|
|
85
|
+
|
|
86
|
+
if (!inspect.State?.Running) {
|
|
87
|
+
await execa("docker", ["start", containerName]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const nextInspect = await inspectContainer(containerName);
|
|
91
|
+
const hostPort = nextInspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
|
|
92
|
+
if (!hostPort) return null;
|
|
93
|
+
|
|
94
|
+
const infra = {
|
|
95
|
+
containerName,
|
|
96
|
+
containerId: nextInspect.Id,
|
|
97
|
+
image: nextInspect.Config?.Image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE,
|
|
98
|
+
user: readStateValue(path.join(infraDir, "user")) || LOCAL_USER,
|
|
99
|
+
password: readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD,
|
|
100
|
+
host: "127.0.0.1",
|
|
101
|
+
port: Number(hostPort),
|
|
102
|
+
};
|
|
103
|
+
await waitForLocalContainerReady(infra);
|
|
104
|
+
return infra;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function inspectContainer(containerName) {
|
|
108
|
+
try {
|
|
109
|
+
const { stdout } = await execa("docker", ["inspect", containerName]);
|
|
110
|
+
const parsed = JSON.parse(stdout);
|
|
111
|
+
return parsed[0] || null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function stopAndRemoveContainer(containerName) {
|
|
118
|
+
try {
|
|
119
|
+
await execa("docker", ["rm", "-f", containerName]);
|
|
120
|
+
} catch {
|
|
121
|
+
// Already gone.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function waitForLocalContainerReady(infra) {
|
|
126
|
+
const startedAt = Date.now();
|
|
127
|
+
while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
|
|
128
|
+
try {
|
|
129
|
+
await execa("docker", [
|
|
130
|
+
"exec",
|
|
131
|
+
infra.containerName,
|
|
132
|
+
"pg_isready",
|
|
133
|
+
"-U",
|
|
134
|
+
infra.user,
|
|
135
|
+
"-d",
|
|
136
|
+
LOCAL_ADMIN_DB,
|
|
137
|
+
]);
|
|
138
|
+
return;
|
|
139
|
+
} catch {
|
|
140
|
+
await sleep(LOCAL_POLL_INTERVAL_MS);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw new Error(`Timed out waiting for local database container ${infra.containerName}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildLocalPostgresContainerLabels(productDir, containerName) {
|
|
148
|
+
return buildDockerResourceLabels(productDir, {
|
|
149
|
+
kind: "postgres-container",
|
|
150
|
+
name: containerName,
|
|
151
|
+
scope: "local-postgres",
|
|
152
|
+
cachePolicy: "product-cache",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sleep(ms) {
|
|
157
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
158
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export async function withLock(lockPath, fn) {
|
|
5
|
+
const lockDir = `${lockPath}.dir`;
|
|
6
|
+
const timeoutMs = 60_000;
|
|
7
|
+
const startedAt = Date.now();
|
|
8
|
+
|
|
9
|
+
while (true) {
|
|
10
|
+
try {
|
|
11
|
+
fs.mkdirSync(lockDir, { recursive: false });
|
|
12
|
+
break;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error.code !== "EEXIST") throw error;
|
|
15
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
16
|
+
throw new Error(`Timed out waiting for lock ${path.basename(lockPath)}`);
|
|
17
|
+
}
|
|
18
|
+
await sleep(200);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} finally {
|
|
25
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sleep(ms) {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { LOCAL_ADMIN_DB } from "./constants.mjs";
|
|
3
|
+
import { readResourceConnectionState } from "./state-files.mjs";
|
|
4
|
+
|
|
5
|
+
export function resolveResourcePostgresInfra(config, processEnv = process.env) {
|
|
6
|
+
const resourceName = String(config.testkit?.database?.resource || "").trim();
|
|
7
|
+
if (!resourceName) {
|
|
8
|
+
throw new Error("Resource-backed Postgres database requires database.resource");
|
|
9
|
+
}
|
|
10
|
+
const connections = parseResourceConnections(processEnv.TESTKIT_RESOURCE_CONNECTIONS_JSON);
|
|
11
|
+
const connection = connections[resourceName];
|
|
12
|
+
if (!connection) {
|
|
13
|
+
const available = Object.keys(connections).sort().join(", ") || "none";
|
|
14
|
+
throw new Error(`Postgres resource "${resourceName}" is not available. Available resources: ${available}`);
|
|
15
|
+
}
|
|
16
|
+
return normalizeResourcePostgresConnection(resourceName, connection);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveResourcePostgresInfraFromState(stateDir, readStateValue, processEnv = process.env) {
|
|
20
|
+
const resourceName = readStateValue(path.join(stateDir, "resource_name"));
|
|
21
|
+
if (!resourceName) return null;
|
|
22
|
+
const connections = parseResourceConnections(processEnv.TESTKIT_RESOURCE_CONNECTIONS_JSON);
|
|
23
|
+
const connection = connections[resourceName] || readResourceConnectionState(stateDir, readStateValue);
|
|
24
|
+
return connection ? normalizeResourcePostgresConnection(resourceName, connection) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseResourceConnections(raw) {
|
|
28
|
+
if (!raw) return {};
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`Invalid TESTKIT_RESOURCE_CONNECTIONS_JSON: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeResourcePostgresConnection(resourceName, connection) {
|
|
38
|
+
const fromUrl = connection.url ? parsePostgresConnectionUrl(connection.url) : {};
|
|
39
|
+
const host = connection.host || fromUrl.host;
|
|
40
|
+
const port = Number(connection.port || fromUrl.port || 5432);
|
|
41
|
+
const user = connection.user || fromUrl.user;
|
|
42
|
+
const password = connection.password || fromUrl.password || "";
|
|
43
|
+
const adminDatabase = connection.adminDatabase || connection.admin_database || fromUrl.database || LOCAL_ADMIN_DB;
|
|
44
|
+
const sslMode = connection.sslMode || connection.sslmode || fromUrl.sslMode || "disable";
|
|
45
|
+
if (!host) throw new Error(`Postgres resource "${resourceName}" connection is missing host`);
|
|
46
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
47
|
+
throw new Error(`Postgres resource "${resourceName}" connection has invalid port`);
|
|
48
|
+
}
|
|
49
|
+
if (!user) throw new Error(`Postgres resource "${resourceName}" connection is missing user`);
|
|
50
|
+
return {
|
|
51
|
+
backend: "resource",
|
|
52
|
+
resourceName,
|
|
53
|
+
host,
|
|
54
|
+
port,
|
|
55
|
+
user,
|
|
56
|
+
password,
|
|
57
|
+
adminDatabase,
|
|
58
|
+
sslMode,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parsePostgresConnectionUrl(rawUrl) {
|
|
63
|
+
const parsed = new URL(rawUrl);
|
|
64
|
+
return {
|
|
65
|
+
host: parsed.hostname,
|
|
66
|
+
port: parsed.port ? Number(parsed.port) : 5432,
|
|
67
|
+
database: decodeURIComponent(parsed.pathname.replace(/^\//, "")) || LOCAL_ADMIN_DB,
|
|
68
|
+
user: decodeURIComponent(parsed.username || ""),
|
|
69
|
+
password: decodeURIComponent(parsed.password || ""),
|
|
70
|
+
sslMode: parsed.searchParams.get("sslmode") || undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { LOCAL_ADMIN_DB } from "./constants.mjs";
|
|
4
|
+
import { buildProductIdentity } from "../ownership/docker.mjs";
|
|
5
|
+
|
|
6
|
+
export function writeLocalInfraState(infraDir, infra) {
|
|
7
|
+
const product = buildProductIdentity(path.resolve(infraDir, "..", "..", ".."));
|
|
8
|
+
fs.writeFileSync(path.join(infraDir, "database_backend"), "local");
|
|
9
|
+
fs.writeFileSync(path.join(infraDir, "container_name"), infra.containerName);
|
|
10
|
+
fs.writeFileSync(path.join(infraDir, "container_id"), infra.containerId);
|
|
11
|
+
fs.writeFileSync(path.join(infraDir, "ownership_product_id"), product.id);
|
|
12
|
+
fs.writeFileSync(path.join(infraDir, "image"), infra.image);
|
|
13
|
+
fs.writeFileSync(path.join(infraDir, "user"), infra.user);
|
|
14
|
+
fs.writeFileSync(path.join(infraDir, "password"), infra.password);
|
|
15
|
+
fs.writeFileSync(path.join(infraDir, "host"), infra.host);
|
|
16
|
+
fs.writeFileSync(path.join(infraDir, "port"), String(infra.port));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeCacheState(cacheDir, config, infra, templateDbName, fingerprint) {
|
|
20
|
+
const backend = config.testkit.database.provider;
|
|
21
|
+
fs.writeFileSync(path.join(cacheDir, "database_backend"), backend);
|
|
22
|
+
fs.writeFileSync(path.join(cacheDir, "template_database_name"), templateDbName);
|
|
23
|
+
fs.writeFileSync(path.join(cacheDir, "template_fingerprint"), fingerprint);
|
|
24
|
+
if (backend === "local") {
|
|
25
|
+
fs.writeFileSync(path.join(cacheDir, "container_name"), infra.containerName);
|
|
26
|
+
} else {
|
|
27
|
+
fs.writeFileSync(path.join(cacheDir, "resource_name"), infra.resourceName);
|
|
28
|
+
writeResourceConnectionState(cacheDir, infra);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeResourceConnectionState(stateDir, infra) {
|
|
33
|
+
fs.writeFileSync(path.join(stateDir, "resource_host"), infra.host);
|
|
34
|
+
fs.writeFileSync(path.join(stateDir, "resource_port"), String(infra.port));
|
|
35
|
+
fs.writeFileSync(path.join(stateDir, "resource_user"), infra.user);
|
|
36
|
+
fs.writeFileSync(path.join(stateDir, "resource_password"), infra.password);
|
|
37
|
+
fs.writeFileSync(path.join(stateDir, "resource_admin_database"), infra.adminDatabase || LOCAL_ADMIN_DB);
|
|
38
|
+
fs.writeFileSync(path.join(stateDir, "resource_sslmode"), infra.sslMode || "disable");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readResourceConnectionState(stateDir, readStateValue) {
|
|
42
|
+
const host = readStateValue(path.join(stateDir, "resource_host"));
|
|
43
|
+
const user = readStateValue(path.join(stateDir, "resource_user"));
|
|
44
|
+
if (!host || !user) return null;
|
|
45
|
+
return {
|
|
46
|
+
host,
|
|
47
|
+
port: Number(readStateValue(path.join(stateDir, "resource_port")) || 5432),
|
|
48
|
+
user,
|
|
49
|
+
password: readStateValue(path.join(stateDir, "resource_password")) || "",
|
|
50
|
+
adminDatabase: readStateValue(path.join(stateDir, "resource_admin_database")) || LOCAL_ADMIN_DB,
|
|
51
|
+
sslMode: readStateValue(path.join(stateDir, "resource_sslmode")) || "disable",
|
|
52
|
+
};
|
|
53
|
+
}
|
package/lib/ownership/docker.mjs
CHANGED
|
@@ -74,6 +74,15 @@ export async function removeDockerContainer(containerRef) {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export async function stopDockerContainer(containerRef) {
|
|
78
|
+
try {
|
|
79
|
+
await execa("docker", ["stop", "--time", "5", containerRef]);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
77
86
|
export function dockerContainerSummary(container) {
|
|
78
87
|
const status = container.running ? "running" : "stopped";
|
|
79
88
|
return `${container.name} (${status})`;
|
package/lib/runner/lifecycle.mjs
CHANGED
|
@@ -189,7 +189,7 @@ export async function cleanupRuns(productDir, { includeActive = false } = {}) {
|
|
|
189
189
|
summary.cleaned.push(manifest);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
192
|
+
summary.resourceCleanup = await cleanupOrphanedLocalInfrastructure(productDir);
|
|
193
193
|
return summary;
|
|
194
194
|
}
|
|
195
195
|
|
|
@@ -126,6 +126,9 @@ export async function cleanup(productDir, options = {}) {
|
|
|
126
126
|
label: "assistant session",
|
|
127
127
|
dryRun,
|
|
128
128
|
});
|
|
129
|
+
for (const target of summary.resourceCleanup?.targets || []) {
|
|
130
|
+
lines.push(formatDatabaseResourceCleanupLine(target, dryRun));
|
|
131
|
+
}
|
|
129
132
|
for (const target of resourceCleanup.targets) {
|
|
130
133
|
lines.push(formatDatabaseResourceCleanupLine(target, dryRun));
|
|
131
134
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.146",
|
|
4
4
|
"description": "Browser bridge helpers for testkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@elench/testkit-protocol": "0.1.
|
|
25
|
+
"@elench/testkit-protocol": "0.1.146"
|
|
26
26
|
},
|
|
27
27
|
"private": false
|
|
28
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.146",
|
|
4
4
|
"description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -98,10 +98,10 @@
|
|
|
98
98
|
},
|
|
99
99
|
"dependencies": {
|
|
100
100
|
"@babel/code-frame": "^7.29.0",
|
|
101
|
-
"@elench/next-analysis": "0.1.
|
|
102
|
-
"@elench/testkit-bridge": "0.1.
|
|
103
|
-
"@elench/testkit-protocol": "0.1.
|
|
104
|
-
"@elench/ts-analysis": "0.1.
|
|
101
|
+
"@elench/next-analysis": "0.1.146",
|
|
102
|
+
"@elench/testkit-bridge": "0.1.146",
|
|
103
|
+
"@elench/testkit-protocol": "0.1.146",
|
|
104
|
+
"@elench/ts-analysis": "0.1.146",
|
|
105
105
|
"@oclif/core": "^4.10.6",
|
|
106
106
|
"@playwright/test": "^1.52.0",
|
|
107
107
|
"esbuild": "^0.25.11",
|