@elench/testkit 0.1.117 → 0.1.119
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 +27 -12
- package/lib/app/doctor.mjs +11 -113
- package/lib/cli/assistant/command-observer.mjs +1 -1
- package/lib/cli/assistant/context-pack.mjs +31 -11
- package/lib/cli/assistant/state.mjs +2 -0
- package/lib/cli/commands/lint.mjs +37 -0
- package/lib/cli/entrypoint.mjs +1 -0
- package/lib/cli/operations/db/schema/refresh/operation.mjs +4 -2
- package/lib/cli/operations/lint/operation.mjs +12 -0
- package/lib/cli/renderers/db-schema/text.mjs +3 -0
- package/lib/cli/renderers/doctor/text.mjs +5 -0
- package/lib/cli/renderers/lint/text.mjs +20 -0
- package/lib/config/database.mjs +9 -13
- package/lib/config-api/database-steps.mjs +132 -0
- package/lib/config-api/index.d.ts +37 -5
- package/lib/config-api/index.mjs +123 -12
- package/lib/database/fingerprint.mjs +2 -2
- package/lib/database/index.mjs +4 -4
- package/lib/database/schema-source.mjs +107 -14
- package/lib/lint/index.mjs +569 -0
- package/lib/repo/state.mjs +164 -0
- package/lib/runner/metadata.mjs +11 -24
- package/lib/runner/template-steps.mjs +8 -0
- package/lib/runner/template.mjs +0 -3
- package/lib/runtime/index.d.ts +43 -0
- package/lib/runtime/index.mjs +24 -0
- package/lib/runtime-src/k6/http-assertions.js +82 -0
- package/lib/shared/configured-steps.mjs +16 -0
- package/lib/ui/index.d.ts +46 -0
- package/lib/ui/index.mjs +11 -0
- package/lib/ui/sandbox.mjs +115 -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 +6 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execFileSync } from "child_process";
|
|
5
|
+
import { parseGitHubRepoSlug } from "../regressions/github.mjs";
|
|
6
|
+
|
|
7
|
+
const IGNORED_DIRS = new Set([".git", ".testkit", "node_modules"]);
|
|
8
|
+
|
|
9
|
+
export function collectRepoState(productDir) {
|
|
10
|
+
const repoRoot = readGit(productDir, ["rev-parse", "--show-toplevel"]);
|
|
11
|
+
if (!repoRoot) {
|
|
12
|
+
const fingerprint = fingerprintDirectory(productDir);
|
|
13
|
+
return {
|
|
14
|
+
kind: "nogit",
|
|
15
|
+
repoRoot: null,
|
|
16
|
+
worktreePath: path.resolve(productDir),
|
|
17
|
+
branch: null,
|
|
18
|
+
detached: false,
|
|
19
|
+
commitSha: null,
|
|
20
|
+
remoteUrl: null,
|
|
21
|
+
repoSlug: null,
|
|
22
|
+
dirty: false,
|
|
23
|
+
dirtyFingerprint: null,
|
|
24
|
+
contentFingerprint: fingerprint,
|
|
25
|
+
cacheKey: `nogit/${fingerprint}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const commitSha = readGit(productDir, ["rev-parse", "--verify", "HEAD"]);
|
|
30
|
+
const branchName = readGit(productDir, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
31
|
+
const remoteUrl = readGit(productDir, ["remote", "get-url", "origin"]);
|
|
32
|
+
const detached = branchName === "HEAD";
|
|
33
|
+
const dirtyFingerprint = fingerprintGitDirtyState(productDir);
|
|
34
|
+
const dirty = Boolean(dirtyFingerprint);
|
|
35
|
+
const baseCommit = commitSha || "unborn";
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
kind: dirty ? "dirty" : "commit",
|
|
39
|
+
repoRoot,
|
|
40
|
+
worktreePath: path.resolve(repoRoot),
|
|
41
|
+
branch: detached ? null : branchName,
|
|
42
|
+
detached,
|
|
43
|
+
commitSha,
|
|
44
|
+
remoteUrl,
|
|
45
|
+
repoSlug: parseGitHubRepoSlug(remoteUrl),
|
|
46
|
+
dirty,
|
|
47
|
+
dirtyFingerprint,
|
|
48
|
+
contentFingerprint: null,
|
|
49
|
+
cacheKey: dirty
|
|
50
|
+
? `dirty/${baseCommit}-${dirtyFingerprint}`
|
|
51
|
+
: `commits/${baseCommit}`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function summarizeRepoStateForMetadata(repoState) {
|
|
56
|
+
if (!repoState) return null;
|
|
57
|
+
return {
|
|
58
|
+
kind: repoState.kind,
|
|
59
|
+
cacheKey: repoState.cacheKey,
|
|
60
|
+
branch: repoState.branch,
|
|
61
|
+
detached: repoState.detached,
|
|
62
|
+
commitSha: repoState.commitSha,
|
|
63
|
+
dirty: repoState.dirty,
|
|
64
|
+
dirtyFingerprint: repoState.dirtyFingerprint,
|
|
65
|
+
contentFingerprint: repoState.contentFingerprint,
|
|
66
|
+
repoRoot: repoState.repoRoot,
|
|
67
|
+
worktreePath: repoState.worktreePath,
|
|
68
|
+
remoteUrl: repoState.remoteUrl,
|
|
69
|
+
repoSlug: repoState.repoSlug,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fingerprintGitDirtyState(productDir) {
|
|
74
|
+
const hash = crypto.createHash("sha256");
|
|
75
|
+
let hasChanges = false;
|
|
76
|
+
|
|
77
|
+
const trackedStatus = readGit(productDir, ["status", "--porcelain=v1", "-uno"]) || "";
|
|
78
|
+
if (trackedStatus.trim()) {
|
|
79
|
+
hasChanges = true;
|
|
80
|
+
hash.update("tracked-status\0");
|
|
81
|
+
hash.update(trackedStatus);
|
|
82
|
+
appendGitOutput(hash, productDir, ["diff", "--binary", "--no-ext-diff"]);
|
|
83
|
+
appendGitOutput(hash, productDir, ["diff", "--binary", "--cached", "--no-ext-diff"]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const untracked = readGit(productDir, ["ls-files", "--others", "--exclude-standard", "-z"]) || "";
|
|
87
|
+
const untrackedFiles = untracked
|
|
88
|
+
.split("\0")
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.filter((entry) => !hasIgnoredPathSegment(entry))
|
|
91
|
+
.sort();
|
|
92
|
+
if (untrackedFiles.length > 0) {
|
|
93
|
+
hasChanges = true;
|
|
94
|
+
hash.update("untracked\0");
|
|
95
|
+
for (const relativePath of untrackedFiles) {
|
|
96
|
+
const absPath = path.join(productDir, relativePath);
|
|
97
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) continue;
|
|
98
|
+
hash.update(`file:${normalizePath(relativePath)}\0`);
|
|
99
|
+
const stat = fs.statSync(absPath);
|
|
100
|
+
hash.update(`${stat.size}\0`);
|
|
101
|
+
hash.update(fs.readFileSync(absPath));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return hasChanges ? hash.digest("hex").slice(0, 24) : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function appendGitOutput(hash, cwd, args) {
|
|
109
|
+
const output = readGit(cwd, args) || "";
|
|
110
|
+
hash.update(args.join(" "));
|
|
111
|
+
hash.update("\0");
|
|
112
|
+
hash.update(output);
|
|
113
|
+
hash.update("\0");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fingerprintDirectory(rootDir) {
|
|
117
|
+
const hash = crypto.createHash("sha256");
|
|
118
|
+
appendDirectoryToHash(hash, rootDir, rootDir);
|
|
119
|
+
return hash.digest("hex").slice(0, 24);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appendDirectoryToHash(hash, rootDir, absPath) {
|
|
123
|
+
if (!fs.existsSync(absPath)) {
|
|
124
|
+
hash.update(`missing:${normalizePath(path.relative(rootDir, absPath))}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const stat = fs.statSync(absPath);
|
|
128
|
+
if (stat.isDirectory()) {
|
|
129
|
+
const relative = path.relative(rootDir, absPath);
|
|
130
|
+
if (relative && hasIgnoredPathSegment(relative)) return;
|
|
131
|
+
hash.update(`dir:${normalizePath(relative)}`);
|
|
132
|
+
for (const entry of fs.readdirSync(absPath).sort()) {
|
|
133
|
+
if (IGNORED_DIRS.has(entry)) continue;
|
|
134
|
+
appendDirectoryToHash(hash, rootDir, path.join(absPath, entry));
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!stat.isFile()) return;
|
|
139
|
+
const relative = normalizePath(path.relative(rootDir, absPath));
|
|
140
|
+
hash.update(`file:${relative}:${stat.size}:${stat.mtimeMs}`);
|
|
141
|
+
hash.update(fs.readFileSync(absPath));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasIgnoredPathSegment(relativePath) {
|
|
145
|
+
return normalizePath(relativePath)
|
|
146
|
+
.split("/")
|
|
147
|
+
.some((segment) => IGNORED_DIRS.has(segment));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizePath(value) {
|
|
151
|
+
return String(value).split(path.sep).join("/");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readGit(cwd, args) {
|
|
155
|
+
try {
|
|
156
|
+
return execFileSync("git", args, {
|
|
157
|
+
cwd,
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
160
|
+
}).trim() || null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
package/lib/runner/metadata.mjs
CHANGED
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import { execFileSync } from "child_process";
|
|
5
4
|
import { fileURLToPath } from "url";
|
|
6
|
-
import {
|
|
5
|
+
import { collectRepoState } from "../repo/state.mjs";
|
|
7
6
|
|
|
8
7
|
export function collectGitMetadata(productDir) {
|
|
9
|
-
const
|
|
10
|
-
try {
|
|
11
|
-
return execaSyncCompat("git", args, { cwd: productDir }).trim() || null;
|
|
12
|
-
} catch {
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const remoteUrl = read(["remote", "get-url", "origin"]);
|
|
8
|
+
const state = collectRepoState(productDir);
|
|
18
9
|
|
|
19
10
|
return {
|
|
20
|
-
branch:
|
|
21
|
-
commitSha:
|
|
22
|
-
repoRoot:
|
|
23
|
-
remoteUrl,
|
|
24
|
-
repoSlug:
|
|
11
|
+
branch: state.branch || (state.detached ? "HEAD" : null),
|
|
12
|
+
commitSha: state.commitSha,
|
|
13
|
+
repoRoot: state.repoRoot,
|
|
14
|
+
remoteUrl: state.remoteUrl,
|
|
15
|
+
repoSlug: state.repoSlug,
|
|
16
|
+
detached: state.detached,
|
|
17
|
+
dirty: state.dirty,
|
|
18
|
+
dirtyFingerprint: state.dirtyFingerprint,
|
|
19
|
+
worktreePath: state.worktreePath,
|
|
25
20
|
};
|
|
26
21
|
}
|
|
27
22
|
|
|
@@ -50,11 +45,3 @@ export function safeUsername() {
|
|
|
50
45
|
return process.env.USER || process.env.USERNAME || null;
|
|
51
46
|
}
|
|
52
47
|
}
|
|
53
|
-
|
|
54
|
-
function execaSyncCompat(command, args, options) {
|
|
55
|
-
return execFileSync(command, args, {
|
|
56
|
-
cwd: options?.cwd,
|
|
57
|
-
encoding: "utf8",
|
|
58
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
59
|
-
});
|
|
60
|
-
}
|
|
@@ -23,6 +23,12 @@ import {
|
|
|
23
23
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
24
24
|
const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
|
|
25
25
|
const CONFIG_ENTRY = path.join(PACKAGE_ROOT, "lib", "config-api", "index.mjs");
|
|
26
|
+
const CONFIG_DATABASE_STEPS_ENTRY = path.join(
|
|
27
|
+
PACKAGE_ROOT,
|
|
28
|
+
"lib",
|
|
29
|
+
"config-api",
|
|
30
|
+
"database-steps.mjs"
|
|
31
|
+
);
|
|
26
32
|
const CONFIG_NEXT_TSCONFIG_ENTRY = path.join(
|
|
27
33
|
PACKAGE_ROOT,
|
|
28
34
|
"lib",
|
|
@@ -159,6 +165,7 @@ async function runConfiguredStep(config, step, env, resolvedToolchain, options =
|
|
|
159
165
|
const bundledModule = await bundleConfiguredModule(config.productDir, step);
|
|
160
166
|
const { exportName } = parseModuleSpecifier(step.specifier);
|
|
161
167
|
const context = {
|
|
168
|
+
args: step.args ?? {},
|
|
162
169
|
databaseUrl: runtimeEnv.DATABASE_URL || null,
|
|
163
170
|
productDir: config.productDir,
|
|
164
171
|
cwd,
|
|
@@ -267,6 +274,7 @@ function resolvePackageSubpath(specifier) {
|
|
|
267
274
|
const subpath = specifier.slice("@elench/testkit".length);
|
|
268
275
|
if (!subpath) return ROOT_ENTRY;
|
|
269
276
|
if (subpath === "/config") return CONFIG_ENTRY;
|
|
277
|
+
if (subpath === "/config/database-steps") return CONFIG_DATABASE_STEPS_ENTRY;
|
|
270
278
|
if (subpath === "/config/next-runtime-tsconfig") return CONFIG_NEXT_TSCONFIG_ENTRY;
|
|
271
279
|
if (subpath === "/drizzle") return DRIZZLE_ENTRY;
|
|
272
280
|
if (subpath === "/env") return ENV_ENTRY;
|
package/lib/runner/template.mjs
CHANGED
|
@@ -202,9 +202,6 @@ function finalizeSourceSchema(sourceSchema, context) {
|
|
|
202
202
|
return {
|
|
203
203
|
...sourceSchema,
|
|
204
204
|
...(typeof sourceSchema.env === "string" ? { env: finalizeString(sourceSchema.env, context) } : {}),
|
|
205
|
-
...(typeof sourceSchema.cachePath === "string"
|
|
206
|
-
? { cachePath: finalizeString(sourceSchema.cachePath, context) }
|
|
207
|
-
: {}),
|
|
208
205
|
};
|
|
209
206
|
}
|
|
210
207
|
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -438,6 +438,41 @@ export declare function expectCondition(
|
|
|
438
438
|
predicate: () => boolean,
|
|
439
439
|
label: string
|
|
440
440
|
): boolean;
|
|
441
|
+
export declare function expectRowCount<T>(
|
|
442
|
+
rows: T[],
|
|
443
|
+
expectedCount: number,
|
|
444
|
+
label?: string | null
|
|
445
|
+
): boolean;
|
|
446
|
+
export declare function expectSingleRow<T>(
|
|
447
|
+
rows: T[],
|
|
448
|
+
label?: string | null
|
|
449
|
+
): T | null;
|
|
450
|
+
export declare function expectNoRows<T>(
|
|
451
|
+
rows: T[],
|
|
452
|
+
label?: string | null
|
|
453
|
+
): boolean;
|
|
454
|
+
export declare function expectField<T extends Record<string, unknown>>(
|
|
455
|
+
row: T,
|
|
456
|
+
field: keyof T | string,
|
|
457
|
+
predicateOrExpected: unknown | ((value: unknown) => boolean),
|
|
458
|
+
label?: string | null
|
|
459
|
+
): boolean;
|
|
460
|
+
export declare function expectTruthyField<T extends Record<string, unknown>>(
|
|
461
|
+
row: T,
|
|
462
|
+
field: keyof T | string,
|
|
463
|
+
label?: string | null
|
|
464
|
+
): boolean;
|
|
465
|
+
export declare function expectType(
|
|
466
|
+
value: unknown,
|
|
467
|
+
typeName: "array" | "bigint" | "boolean" | "function" | "null" | "number" | "object" | "string" | "symbol" | "undefined",
|
|
468
|
+
label?: string | null
|
|
469
|
+
): boolean;
|
|
470
|
+
export declare function captureError(fn: () => unknown): unknown | null;
|
|
471
|
+
export declare function expectError(
|
|
472
|
+
errorOrFn: unknown | (() => unknown) | null,
|
|
473
|
+
predicate?: ((error: unknown) => boolean) | null,
|
|
474
|
+
label?: string | null
|
|
475
|
+
): boolean;
|
|
441
476
|
|
|
442
477
|
export declare function runAuthGateChecks(
|
|
443
478
|
rawReq: RawRequestClient,
|
|
@@ -458,17 +493,25 @@ export declare function runPaginationChecks(
|
|
|
458
493
|
): void;
|
|
459
494
|
|
|
460
495
|
export interface RuntimeExpectNamespace {
|
|
496
|
+
captureError: typeof captureError;
|
|
461
497
|
condition: typeof expectCondition;
|
|
462
498
|
error: {
|
|
499
|
+
captured: typeof expectError;
|
|
463
500
|
message: typeof expectErrorMessage;
|
|
464
501
|
shape: typeof expectErrorShape;
|
|
465
502
|
};
|
|
503
|
+
field: typeof expectField;
|
|
466
504
|
json: typeof expectJson;
|
|
467
505
|
jsonPath: typeof expectJsonPath;
|
|
506
|
+
noRows: typeof expectNoRows;
|
|
468
507
|
notStatus: typeof expectNotStatus;
|
|
469
508
|
response: typeof expectResponse;
|
|
509
|
+
rowCount: typeof expectRowCount;
|
|
510
|
+
singleRow: typeof expectSingleRow;
|
|
470
511
|
status: typeof expectStatus;
|
|
471
512
|
statusOneOf: typeof expectStatusOneOf;
|
|
513
|
+
truthyField: typeof expectTruthyField;
|
|
514
|
+
type: typeof expectType;
|
|
472
515
|
value: typeof expectValue;
|
|
473
516
|
}
|
|
474
517
|
|
package/lib/runtime/index.mjs
CHANGED
|
@@ -39,15 +39,23 @@ export {
|
|
|
39
39
|
singleIterationOptions,
|
|
40
40
|
} from "../runtime-src/k6/checks.js";
|
|
41
41
|
export {
|
|
42
|
+
captureError,
|
|
42
43
|
expectCondition,
|
|
44
|
+
expectError,
|
|
43
45
|
expectErrorMessage,
|
|
44
46
|
expectErrorShape,
|
|
47
|
+
expectField,
|
|
48
|
+
expectNoRows,
|
|
45
49
|
expectJson,
|
|
46
50
|
expectJsonPath,
|
|
47
51
|
expectNotStatus,
|
|
48
52
|
expectResponse,
|
|
53
|
+
expectRowCount,
|
|
54
|
+
expectSingleRow,
|
|
49
55
|
expectStatus,
|
|
50
56
|
expectStatusOneOf,
|
|
57
|
+
expectTruthyField,
|
|
58
|
+
expectType,
|
|
51
59
|
expectValue,
|
|
52
60
|
} from "../runtime-src/k6/http-assertions.js";
|
|
53
61
|
export {
|
|
@@ -72,15 +80,23 @@ export {
|
|
|
72
80
|
} from "../runtime-src/k6/http.js";
|
|
73
81
|
|
|
74
82
|
import {
|
|
83
|
+
captureError,
|
|
75
84
|
expectCondition,
|
|
85
|
+
expectError,
|
|
76
86
|
expectErrorMessage,
|
|
77
87
|
expectErrorShape,
|
|
88
|
+
expectField,
|
|
89
|
+
expectNoRows,
|
|
78
90
|
expectJson,
|
|
79
91
|
expectJsonPath,
|
|
80
92
|
expectNotStatus,
|
|
81
93
|
expectResponse,
|
|
94
|
+
expectRowCount,
|
|
95
|
+
expectSingleRow,
|
|
82
96
|
expectStatus,
|
|
83
97
|
expectStatusOneOf,
|
|
98
|
+
expectTruthyField,
|
|
99
|
+
expectType,
|
|
84
100
|
expectValue,
|
|
85
101
|
} from "../runtime-src/k6/http-assertions.js";
|
|
86
102
|
import {
|
|
@@ -90,17 +106,25 @@ import {
|
|
|
90
106
|
import { safeJson } from "../runtime-src/k6/http.js";
|
|
91
107
|
|
|
92
108
|
export const expect = {
|
|
109
|
+
captureError,
|
|
93
110
|
condition: expectCondition,
|
|
94
111
|
error: {
|
|
112
|
+
captured: expectError,
|
|
95
113
|
message: expectErrorMessage,
|
|
96
114
|
shape: expectErrorShape,
|
|
97
115
|
},
|
|
116
|
+
field: expectField,
|
|
98
117
|
json: expectJson,
|
|
99
118
|
jsonPath: expectJsonPath,
|
|
119
|
+
noRows: expectNoRows,
|
|
100
120
|
notStatus: expectNotStatus,
|
|
101
121
|
response: expectResponse,
|
|
122
|
+
rowCount: expectRowCount,
|
|
123
|
+
singleRow: expectSingleRow,
|
|
102
124
|
status: expectStatus,
|
|
103
125
|
statusOneOf: expectStatusOneOf,
|
|
126
|
+
truthyField: expectTruthyField,
|
|
127
|
+
type: expectType,
|
|
104
128
|
value: expectValue,
|
|
105
129
|
};
|
|
106
130
|
|
|
@@ -152,6 +152,77 @@ export function expectCondition(predicate, label) {
|
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
export function expectRowCount(rows, expectedCount, label = null) {
|
|
156
|
+
const expected = Number(expectedCount);
|
|
157
|
+
if (!Number.isInteger(expected) || expected < 0) {
|
|
158
|
+
throw new Error("expectRowCount requires a non-negative integer count");
|
|
159
|
+
}
|
|
160
|
+
return expectValue(
|
|
161
|
+
rows,
|
|
162
|
+
(value) => Array.isArray(value) && value.length === expected,
|
|
163
|
+
normalizeLabel(label, `row count is ${expected}`)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function expectSingleRow(rows, label = "query returns one row") {
|
|
168
|
+
if (!expectRowCount(rows, 1, label)) return null;
|
|
169
|
+
return rows[0];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function expectNoRows(rows, label = "query returns no rows") {
|
|
173
|
+
return expectRowCount(rows, 0, label);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function expectField(row, field, predicateOrExpected, label = null) {
|
|
177
|
+
const fieldName = String(field || "");
|
|
178
|
+
return expectValue(
|
|
179
|
+
row?.[fieldName],
|
|
180
|
+
normalizeFieldPredicate(predicateOrExpected),
|
|
181
|
+
normalizeLabel(label, `${fieldName || "field"} matches expectation`)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function expectTruthyField(row, field, label = null) {
|
|
186
|
+
const fieldName = String(field || "");
|
|
187
|
+
return expectField(
|
|
188
|
+
row,
|
|
189
|
+
fieldName,
|
|
190
|
+
(value) => Boolean(value),
|
|
191
|
+
normalizeLabel(label, `${fieldName || "field"} is truthy`)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function expectType(value, typeName, label = null) {
|
|
196
|
+
const expected = String(typeName || "").trim();
|
|
197
|
+
if (!expected) throw new Error("expectType requires a type name");
|
|
198
|
+
return expectValue(
|
|
199
|
+
value,
|
|
200
|
+
(actual) => actualType(actual) === expected,
|
|
201
|
+
normalizeLabel(label, `type is ${expected}`)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function captureError(fn) {
|
|
206
|
+
try {
|
|
207
|
+
fn();
|
|
208
|
+
return null;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function expectError(errorOrFn, predicate = null, label = null) {
|
|
215
|
+
const error = typeof errorOrFn === "function" ? captureError(errorOrFn) : errorOrFn;
|
|
216
|
+
return expectValue(
|
|
217
|
+
error,
|
|
218
|
+
(value) => {
|
|
219
|
+
if (!value) return false;
|
|
220
|
+
return typeof predicate === "function" ? Boolean(predicate(value)) : true;
|
|
221
|
+
},
|
|
222
|
+
normalizeLabel(label, "error is captured")
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
155
226
|
function buildHttpAssertionDetail({ kind, title, trace, expected, actual, response, message }) {
|
|
156
227
|
return {
|
|
157
228
|
kind,
|
|
@@ -238,6 +309,17 @@ function toValuePreview(value) {
|
|
|
238
309
|
}
|
|
239
310
|
}
|
|
240
311
|
|
|
312
|
+
function normalizeFieldPredicate(predicateOrExpected) {
|
|
313
|
+
if (typeof predicateOrExpected === "function") return predicateOrExpected;
|
|
314
|
+
return (value) => Object.is(value, predicateOrExpected);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function actualType(value) {
|
|
318
|
+
if (value === null) return "null";
|
|
319
|
+
if (Array.isArray(value)) return "array";
|
|
320
|
+
return typeof value;
|
|
321
|
+
}
|
|
322
|
+
|
|
241
323
|
function normalizeLabel(value, fallback) {
|
|
242
324
|
const normalized = typeof value === "string" ? value.trim() : "";
|
|
243
325
|
return normalized.length > 0 ? normalized : fallback;
|
|
@@ -55,31 +55,37 @@ export function normalizeConfiguredStep(step, label) {
|
|
|
55
55
|
if (kind === "command") {
|
|
56
56
|
const cmd = normalizeOptionalString(step.run);
|
|
57
57
|
if (!cmd) throw new Error(`${label}.run must be a non-empty string`);
|
|
58
|
+
const args = normalizeStepArgs(step.args, label);
|
|
58
59
|
return {
|
|
59
60
|
kind,
|
|
60
61
|
cmd,
|
|
61
62
|
cwd: normalizeOptionalString(step.cwd),
|
|
62
63
|
inputs: normalizeConfiguredStepInputs(step.inputs, label),
|
|
64
|
+
...(args !== undefined ? { args } : {}),
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
if (kind === "sql-file") {
|
|
66
68
|
const filePath = normalizeOptionalString(step.path);
|
|
67
69
|
if (!filePath) throw new Error(`${label}.path must be a non-empty string`);
|
|
70
|
+
const args = normalizeStepArgs(step.args, label);
|
|
68
71
|
return {
|
|
69
72
|
kind,
|
|
70
73
|
path: filePath,
|
|
71
74
|
cwd: normalizeOptionalString(step.cwd),
|
|
72
75
|
inputs: normalizeConfiguredStepInputs(step.inputs, label),
|
|
76
|
+
...(args !== undefined ? { args } : {}),
|
|
73
77
|
};
|
|
74
78
|
}
|
|
75
79
|
if (kind === "module") {
|
|
76
80
|
const specifier = normalizeOptionalString(step.target);
|
|
77
81
|
if (!specifier) throw new Error(`${label}.target must be a non-empty string`);
|
|
82
|
+
const args = normalizeStepArgs(step.args, label);
|
|
78
83
|
return {
|
|
79
84
|
kind,
|
|
80
85
|
specifier,
|
|
81
86
|
cwd: normalizeOptionalString(step.cwd),
|
|
82
87
|
inputs: normalizeConfiguredStepInputs(step.inputs, label),
|
|
88
|
+
...(args !== undefined ? { args } : {}),
|
|
83
89
|
};
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -94,6 +100,15 @@ export function parseModuleSpecifier(specifier) {
|
|
|
94
100
|
};
|
|
95
101
|
}
|
|
96
102
|
|
|
103
|
+
export function normalizeStepArgs(value, label) {
|
|
104
|
+
if (value == null) return undefined;
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(JSON.stringify(value));
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`${label}.args must be JSON-serializable`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
97
112
|
export function isBareModuleSpecifier(modulePath) {
|
|
98
113
|
if (typeof modulePath !== "string" || modulePath.length === 0) return false;
|
|
99
114
|
if (modulePath.startsWith("./") || modulePath.startsWith("../")) return false;
|
|
@@ -155,6 +170,7 @@ export function finalizeConfiguredStep(step, transform) {
|
|
|
155
170
|
...(typeof step.path === "string" ? { path: transform(step.path) } : {}),
|
|
156
171
|
...(typeof step.specifier === "string" ? { specifier: transform(step.specifier) } : {}),
|
|
157
172
|
inputs: finalizeConfiguredInputs(step.inputs || [], transform),
|
|
173
|
+
...(step.args !== undefined ? { args: step.args } : {}),
|
|
158
174
|
};
|
|
159
175
|
}
|
|
160
176
|
|
package/lib/ui/index.d.ts
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
1
|
export * from "@playwright/test";
|
|
2
2
|
export { defineConfig } from "../playwright/index";
|
|
3
3
|
export type { PlaywrightConfigOptions } from "../playwright/index";
|
|
4
|
+
|
|
5
|
+
export interface UiSandboxOptions {
|
|
6
|
+
allowRemote?: boolean;
|
|
7
|
+
backendBaseUrl?: string;
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
frontendBaseUrl?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
prefix?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UiSandboxFixture {
|
|
15
|
+
id: string;
|
|
16
|
+
backendBaseUrl: string;
|
|
17
|
+
frontendBaseUrl: string;
|
|
18
|
+
page: import("@playwright/test").Page;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export declare function createUiSandbox(options?: UiSandboxOptions): {
|
|
22
|
+
expect: typeof import("@playwright/test").expect;
|
|
23
|
+
test: import("@playwright/test").TestType<
|
|
24
|
+
import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
|
|
25
|
+
testkitSandbox: UiSandboxFixture;
|
|
26
|
+
},
|
|
27
|
+
import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions
|
|
28
|
+
>;
|
|
29
|
+
};
|
|
30
|
+
export declare function createSandboxId(options?: Pick<UiSandboxOptions, "prefix">): string;
|
|
31
|
+
export declare function resolveFrontendBaseUrl(options?: UiSandboxOptions): string;
|
|
32
|
+
export declare function resolveBackendBaseUrl(options?: UiSandboxOptions): string;
|
|
33
|
+
export declare function assertSafeUiTarget(url: string, options?: Pick<UiSandboxOptions, "allowRemote">): string;
|
|
34
|
+
export declare function assertSafeUiTargets(
|
|
35
|
+
urls?: string[],
|
|
36
|
+
options?: Pick<UiSandboxOptions, "allowRemote">
|
|
37
|
+
): string[];
|
|
38
|
+
export declare function createAuthedApiClient(
|
|
39
|
+
playwrightRequest?: typeof import("@playwright/test").request,
|
|
40
|
+
options?: UiSandboxOptions
|
|
41
|
+
): Promise<import("@playwright/test").APIRequestContext>;
|
|
42
|
+
export declare function waitForUiSettled(
|
|
43
|
+
page: import("@playwright/test").Page,
|
|
44
|
+
options?: { frames?: number; timeout?: number }
|
|
45
|
+
): Promise<void>;
|
|
46
|
+
export declare function waitForAnimationFrames(
|
|
47
|
+
page: import("@playwright/test").Page,
|
|
48
|
+
frames?: number
|
|
49
|
+
): Promise<void>;
|
package/lib/ui/index.mjs
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
export * from "@playwright/test";
|
|
2
2
|
export { defineConfig } from "../playwright/index.mjs";
|
|
3
|
+
export {
|
|
4
|
+
assertSafeUiTarget,
|
|
5
|
+
assertSafeUiTargets,
|
|
6
|
+
createAuthedApiClient,
|
|
7
|
+
createSandboxId,
|
|
8
|
+
createUiSandbox,
|
|
9
|
+
resolveBackendBaseUrl,
|
|
10
|
+
resolveFrontendBaseUrl,
|
|
11
|
+
waitForAnimationFrames,
|
|
12
|
+
waitForUiSettled,
|
|
13
|
+
} from "./sandbox.mjs";
|