@elench/testkit 0.1.144 → 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/README.md +10 -0
- package/lib/cli/commands/cleanup.mjs +12 -0
- package/lib/cli/operations/cleanup/operation.mjs +3 -0
- package/lib/cli/operations/db/schema/refresh/operation.mjs +11 -1
- package/lib/cli/operations/db/schema/verify/operation.mjs +11 -1
- package/lib/cli/operations/destroy/operation.mjs +6 -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 +74 -590
- 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 +144 -0
- package/lib/runner/lifecycle.mjs +1 -1
- package/lib/runner/maintenance.mjs +26 -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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
|
|
6
|
+
export const TESTKIT_MANAGED_LABEL = "com.elench.testkit.managed";
|
|
7
|
+
export const TESTKIT_RESOURCE_KIND_LABEL = "com.elench.testkit.resource.kind";
|
|
8
|
+
export const TESTKIT_RESOURCE_NAME_LABEL = "com.elench.testkit.resource.name";
|
|
9
|
+
export const TESTKIT_PRODUCT_DIR_LABEL = "com.elench.testkit.product.dir";
|
|
10
|
+
export const TESTKIT_PRODUCT_ID_LABEL = "com.elench.testkit.product.id";
|
|
11
|
+
export const TESTKIT_SCOPE_LABEL = "com.elench.testkit.scope";
|
|
12
|
+
export const TESTKIT_CACHE_POLICY_LABEL = "com.elench.testkit.cache.policy";
|
|
13
|
+
export const TESTKIT_CREATED_AT_LABEL = "com.elench.testkit.created-at";
|
|
14
|
+
|
|
15
|
+
const TESTKIT_POSTGRES_CONTAINER_PREFIX = "testkit_pg_";
|
|
16
|
+
|
|
17
|
+
export function buildProductIdentity(productDir) {
|
|
18
|
+
const canonicalDir = canonicalizeProductDir(productDir);
|
|
19
|
+
return {
|
|
20
|
+
dir: canonicalDir,
|
|
21
|
+
id: hashString(canonicalDir, 24),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildDockerResourceLabels(productDir, resource) {
|
|
26
|
+
const product = buildProductIdentity(productDir);
|
|
27
|
+
return {
|
|
28
|
+
[TESTKIT_MANAGED_LABEL]: "true",
|
|
29
|
+
[TESTKIT_RESOURCE_KIND_LABEL]: resource.kind,
|
|
30
|
+
[TESTKIT_RESOURCE_NAME_LABEL]: resource.name,
|
|
31
|
+
[TESTKIT_PRODUCT_DIR_LABEL]: product.dir,
|
|
32
|
+
[TESTKIT_PRODUCT_ID_LABEL]: product.id,
|
|
33
|
+
[TESTKIT_SCOPE_LABEL]: resource.scope || resource.kind,
|
|
34
|
+
[TESTKIT_CACHE_POLICY_LABEL]: resource.cachePolicy || "ephemeral",
|
|
35
|
+
[TESTKIT_CREATED_AT_LABEL]: resource.createdAt || new Date().toISOString(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function dockerLabelArgs(labels) {
|
|
40
|
+
return Object.entries(labels).flatMap(([key, value]) => ["--label", `${key}=${String(value)}`]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function inspectDockerContainer(containerRef) {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execa("docker", ["inspect", containerRef]);
|
|
46
|
+
const parsed = JSON.parse(stdout);
|
|
47
|
+
return parsed[0] || null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function listManagedDockerContainers() {
|
|
54
|
+
return listDockerContainers([
|
|
55
|
+
"--filter",
|
|
56
|
+
`label=${TESTKIT_MANAGED_LABEL}=true`,
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function listLegacyTestkitPostgresContainers() {
|
|
61
|
+
const containers = await listDockerContainers([]);
|
|
62
|
+
return containers.filter((container) => {
|
|
63
|
+
if (!container.name.startsWith(TESTKIT_POSTGRES_CONTAINER_PREFIX)) return false;
|
|
64
|
+
return container.labels[TESTKIT_MANAGED_LABEL] !== "true";
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function removeDockerContainer(containerRef) {
|
|
69
|
+
try {
|
|
70
|
+
await execa("docker", ["rm", "-f", containerRef]);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
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
|
+
|
|
86
|
+
export function dockerContainerSummary(container) {
|
|
87
|
+
const status = container.running ? "running" : "stopped";
|
|
88
|
+
return `${container.name} (${status})`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function listDockerContainers(filters) {
|
|
92
|
+
let stdout = "";
|
|
93
|
+
try {
|
|
94
|
+
const result = await execa("docker", [
|
|
95
|
+
"ps",
|
|
96
|
+
"-a",
|
|
97
|
+
"--format",
|
|
98
|
+
"{{.ID}}",
|
|
99
|
+
...filters,
|
|
100
|
+
]);
|
|
101
|
+
stdout = result.stdout;
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ids = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
107
|
+
if (ids.length === 0) return [];
|
|
108
|
+
|
|
109
|
+
const containers = [];
|
|
110
|
+
for (const id of ids) {
|
|
111
|
+
const inspect = await inspectDockerContainer(id);
|
|
112
|
+
if (inspect) containers.push(inspect);
|
|
113
|
+
}
|
|
114
|
+
return containers.map(normalizeInspectContainer).filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeInspectContainer(inspect) {
|
|
118
|
+
if (!inspect?.Id) return null;
|
|
119
|
+
const name = String(inspect.Name || "").replace(/^\//, "");
|
|
120
|
+
const labels = inspect.Config?.Labels || {};
|
|
121
|
+
return {
|
|
122
|
+
id: inspect.Id,
|
|
123
|
+
shortId: inspect.Id.slice(0, 12),
|
|
124
|
+
name,
|
|
125
|
+
image: inspect.Config?.Image || "",
|
|
126
|
+
createdAt: inspect.Created || null,
|
|
127
|
+
running: Boolean(inspect.State?.Running),
|
|
128
|
+
status: inspect.State?.Status || "unknown",
|
|
129
|
+
labels,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function canonicalizeProductDir(productDir) {
|
|
134
|
+
const resolved = path.resolve(productDir || process.cwd());
|
|
135
|
+
try {
|
|
136
|
+
return fs.realpathSync.native(resolved);
|
|
137
|
+
} catch {
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hashString(value, length = 24) {
|
|
143
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, length);
|
|
144
|
+
}
|
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
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import {
|
|
4
|
+
cleanupGlobalLocalDatabaseResources,
|
|
5
|
+
cleanupOwnedLocalDatabaseResources,
|
|
4
6
|
cleanupOrphanedLocalInfrastructure,
|
|
5
7
|
destroyRuntimeDatabase,
|
|
6
8
|
destroyServiceDatabaseCache,
|
|
9
|
+
formatDatabaseResourceCleanupLine,
|
|
7
10
|
isDatabaseStateDir,
|
|
8
11
|
} from "../database/index.mjs";
|
|
9
12
|
import { cleanupRuns, formatRunSummary, isPidRunning, listRunManifests } from "./lifecycle.mjs";
|
|
@@ -51,6 +54,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
51
54
|
const allConfigs = options.allConfigs || [];
|
|
52
55
|
const serviceName = options.serviceName || null;
|
|
53
56
|
const cache = normalizeCacheSelection(options.cache);
|
|
57
|
+
const cleanResources = Boolean(options.resources || options.globalResources || options.includeLegacyResources);
|
|
54
58
|
const summary = dryRun
|
|
55
59
|
? collectRunCleanupPreview(productDir)
|
|
56
60
|
: await cleanupRuns(productDir, { includeActive: false });
|
|
@@ -66,6 +70,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
66
70
|
const runtimeCleaned = [];
|
|
67
71
|
const bundleCleaned = [];
|
|
68
72
|
const assistantCleaned = [];
|
|
73
|
+
let resourceCleanup = { targets: [], removed: [], kept: [] };
|
|
69
74
|
|
|
70
75
|
if (!dryRun) {
|
|
71
76
|
for (const target of targets.runtime) {
|
|
@@ -83,6 +88,19 @@ export async function cleanup(productDir, options = {}) {
|
|
|
83
88
|
pruneKnownEmptyDirs(productDir);
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
if (cleanResources) {
|
|
92
|
+
resourceCleanup = options.globalResources
|
|
93
|
+
? await cleanupGlobalLocalDatabaseResources({
|
|
94
|
+
productDir,
|
|
95
|
+
dryRun,
|
|
96
|
+
includeLegacy: Boolean(options.includeLegacyResources),
|
|
97
|
+
})
|
|
98
|
+
: await cleanupOwnedLocalDatabaseResources(productDir, {
|
|
99
|
+
dryRun,
|
|
100
|
+
includeLegacy: Boolean(options.includeLegacyResources),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
const lines = [];
|
|
87
105
|
for (const manifest of summary.cleaned) {
|
|
88
106
|
lines.push(`${dryRun ? "Would clean" : "Cleaned"} stale run ${formatRunSummary(manifest)}`);
|
|
@@ -108,6 +126,12 @@ export async function cleanup(productDir, options = {}) {
|
|
|
108
126
|
label: "assistant session",
|
|
109
127
|
dryRun,
|
|
110
128
|
});
|
|
129
|
+
for (const target of summary.resourceCleanup?.targets || []) {
|
|
130
|
+
lines.push(formatDatabaseResourceCleanupLine(target, dryRun));
|
|
131
|
+
}
|
|
132
|
+
for (const target of resourceCleanup.targets) {
|
|
133
|
+
lines.push(formatDatabaseResourceCleanupLine(target, dryRun));
|
|
134
|
+
}
|
|
111
135
|
|
|
112
136
|
if (lines.length === 0) {
|
|
113
137
|
return {
|
|
@@ -118,6 +142,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
118
142
|
bundleCleaned,
|
|
119
143
|
assistantCleaned,
|
|
120
144
|
localCleaned,
|
|
145
|
+
resourceCleanup,
|
|
121
146
|
lines: ["No stale runs to clean."],
|
|
122
147
|
};
|
|
123
148
|
}
|
|
@@ -130,6 +155,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
130
155
|
bundleCleaned,
|
|
131
156
|
assistantCleaned,
|
|
132
157
|
localCleaned,
|
|
158
|
+
resourceCleanup,
|
|
133
159
|
lines,
|
|
134
160
|
};
|
|
135
161
|
}
|
|
@@ -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",
|