slicetest 0.4.0 → 0.6.0
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 +293 -7
- package/dist/app.js +2 -2
- package/dist/auth.d.ts +55 -0
- package/dist/auth.js +140 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +18 -6
- package/dist/config.d.ts +72 -5
- package/dist/config.js +74 -6
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +28 -0
- package/dist/db.js +68 -0
- package/dist/doctor.js +17 -5
- package/dist/drivers/driver.d.ts +27 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +2 -1
- package/dist/drivers/sqlite.js +52 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +24 -1
- package/dist/http.js +86 -9
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/init.js +245 -24
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +10 -0
- package/dist/openapi.js +22 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/runtime.d.ts +18 -0
- package/dist/runtime.js +163 -23
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +46 -0
- package/dist/stub.js +109 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +76 -16
- package/dist/yaml.d.ts +67 -1
- package/dist/yaml.js +87 -3
- package/package.json +12 -4
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +320 -0
package/dist/neon.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neon's HTTP query endpoint, served from the test database, so apps on the Neon
|
|
3
|
+
* serverless driver (`neon()` from @neondatabase/serverless, drizzle-orm/neon-http,
|
|
4
|
+
* @vercel/postgres's `sql`) run against the local Postgres without a code change.
|
|
5
|
+
*
|
|
6
|
+
* The driver posts each query to `https://api.<domain>/sql` for a connection string
|
|
7
|
+
* at `<endpoint>.<domain>`; with `db.neon` the app gets such a string and the
|
|
8
|
+
* intercepting proxy hands that host to this server.
|
|
9
|
+
*/
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import pg from "pg";
|
|
12
|
+
/** The made-up domain of the connection string the app gets; `*.` of it is intercepted. */
|
|
13
|
+
export const NEON_DOMAIN = "neon.slicetest";
|
|
14
|
+
/** `url` (the real test database) with its host replaced by a Neon-style endpoint host. */
|
|
15
|
+
export function neonUrl(url) {
|
|
16
|
+
const u = new URL(url);
|
|
17
|
+
u.hostname = `ep-slicetest.${NEON_DOMAIN}`;
|
|
18
|
+
u.port = "";
|
|
19
|
+
u.searchParams.set("sslmode", "require");
|
|
20
|
+
return u.toString();
|
|
21
|
+
}
|
|
22
|
+
// Values stay as Postgres's text: the driver parses them itself from each field's type.
|
|
23
|
+
const RAW = { getTypeParser: () => (value) => value };
|
|
24
|
+
const ERROR_FIELDS = ["severity", "code", "detail", "hint", "position", "internalPosition", "internalQuery", "where", "schema", "table", "column", "dataType", "constraint", "file", "line", "routine"];
|
|
25
|
+
export class NeonEndpoint {
|
|
26
|
+
#server;
|
|
27
|
+
#pool;
|
|
28
|
+
constructor(databaseUrl) {
|
|
29
|
+
this.#pool = new pg.Pool({ connectionString: databaseUrl, max: 5, types: RAW });
|
|
30
|
+
this.#pool.on("error", () => { });
|
|
31
|
+
this.#server = http.createServer((req, res) => {
|
|
32
|
+
this.#handle(req, res).catch((e) => res.headersSent || res.writeHead(500).end(String(e)));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** `databaseUrl` is where queries really run: the worker database, or the `db.queries` proxy in front of it. */
|
|
36
|
+
static async start(databaseUrl) {
|
|
37
|
+
const endpoint = new NeonEndpoint(databaseUrl);
|
|
38
|
+
await new Promise((resolve) => endpoint.#server.listen(0, "127.0.0.1", resolve));
|
|
39
|
+
return endpoint;
|
|
40
|
+
}
|
|
41
|
+
get port() {
|
|
42
|
+
return this.#server.address().port;
|
|
43
|
+
}
|
|
44
|
+
/** Serve a connection the intercepting proxy terminated. */
|
|
45
|
+
attach(socket) {
|
|
46
|
+
this.#server.emit("connection", socket);
|
|
47
|
+
}
|
|
48
|
+
async #handle(req, res) {
|
|
49
|
+
const send = (status, body) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
50
|
+
if (req.method !== "POST" || !req.url?.startsWith("/sql"))
|
|
51
|
+
return send(404, { message: `slicetest's Neon endpoint only answers POST /sql, not ${req.method} ${req.url}` });
|
|
52
|
+
let body;
|
|
53
|
+
try {
|
|
54
|
+
let raw = "";
|
|
55
|
+
for await (const chunk of req)
|
|
56
|
+
raw += chunk;
|
|
57
|
+
body = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return send(400, { message: "invalid JSON body" });
|
|
61
|
+
}
|
|
62
|
+
const arrayMode = req.headers["neon-array-mode"] === "true";
|
|
63
|
+
const client = await this.#pool.connect();
|
|
64
|
+
try {
|
|
65
|
+
if ("queries" in body) {
|
|
66
|
+
const level = req.headers["neon-batch-isolation-level"];
|
|
67
|
+
const readOnly = req.headers["neon-batch-read-only"] === "true";
|
|
68
|
+
const deferrable = req.headers["neon-batch-deferrable"] === "true";
|
|
69
|
+
const mode = [level && `ISOLATION LEVEL ${String(level).replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}`, readOnly && "READ ONLY", deferrable && "DEFERRABLE"].filter(Boolean);
|
|
70
|
+
await client.query(`BEGIN${mode.length ? ` ${mode.join(" ")}` : ""}`);
|
|
71
|
+
try {
|
|
72
|
+
const results = [];
|
|
73
|
+
for (const q of body.queries)
|
|
74
|
+
results.push(await run(client, q, arrayMode));
|
|
75
|
+
await client.query("COMMIT");
|
|
76
|
+
return send(200, { results });
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
await client.query("ROLLBACK").catch(() => { });
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return send(200, await run(client, body, arrayMode));
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
// Shaped like Neon's: a message plus Postgres's error fields, which the driver copies onto its error.
|
|
87
|
+
const err = e;
|
|
88
|
+
return send(400, { message: err.message, ...Object.fromEntries(ERROR_FIELDS.filter((f) => err[f] !== undefined).map((f) => [f, err[f]])) });
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
client.release();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async close() {
|
|
95
|
+
this.#server.closeAllConnections();
|
|
96
|
+
await new Promise((resolve) => this.#server.close(resolve));
|
|
97
|
+
await this.#pool.end();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function run(client, { query, params = [] }, arrayMode) {
|
|
101
|
+
const result = await client.query({ text: query, values: params, rowMode: "array" });
|
|
102
|
+
const fields = result.fields.map((f) => ({
|
|
103
|
+
name: f.name,
|
|
104
|
+
tableID: f.tableID,
|
|
105
|
+
columnID: f.columnID,
|
|
106
|
+
dataTypeID: f.dataTypeID,
|
|
107
|
+
dataTypeSize: f.dataTypeSize,
|
|
108
|
+
dataTypeModifier: f.dataTypeModifier,
|
|
109
|
+
format: "text",
|
|
110
|
+
}));
|
|
111
|
+
const rows = arrayMode ? result.rows : result.rows.map((r) => Object.fromEntries(fields.map((f, i) => [f.name, r[i]])));
|
|
112
|
+
return { command: result.command, rowCount: result.rowCount, rows, fields, rowAsArray: arrayMode };
|
|
113
|
+
}
|
package/dist/openapi.d.ts
CHANGED
|
@@ -15,6 +15,14 @@ export interface OperationSketch {
|
|
|
15
15
|
responses: string[];
|
|
16
16
|
/** Top-level properties of the first 2xx JSON response. */
|
|
17
17
|
createdFields: string[];
|
|
18
|
+
/**
|
|
19
|
+
* The operation requires a bearer token (an `http: bearer`, `oauth2` or
|
|
20
|
+
* `openIdConnect` security scheme), with the scopes it lists. Undefined when
|
|
21
|
+
* it needs no token or accepts other credentials only.
|
|
22
|
+
*/
|
|
23
|
+
bearer?: {
|
|
24
|
+
scopes: string[];
|
|
25
|
+
};
|
|
18
26
|
}
|
|
19
27
|
export interface Message {
|
|
20
28
|
status?: number;
|
|
@@ -72,4 +80,6 @@ export declare function formatCoverage(spec: OpenApiSpec, hits: Set<string>): {
|
|
|
72
80
|
markdown: string;
|
|
73
81
|
text: string;
|
|
74
82
|
};
|
|
83
|
+
/** Where a spec fetched from the app (`openapi.fromApp`) is kept for the coverage report. */
|
|
84
|
+
export declare function appSpecFile(coverageDir: string): string;
|
|
75
85
|
export {};
|
package/dist/openapi.js
CHANGED
|
@@ -157,9 +157,27 @@ export class OpenApiSpec {
|
|
|
157
157
|
json: json ?? undefined,
|
|
158
158
|
responses,
|
|
159
159
|
createdFields: okSchema?.properties ? Object.keys(okSchema.properties) : [],
|
|
160
|
+
bearer: this.#bearer(op.security ?? this.doc.security),
|
|
160
161
|
};
|
|
161
162
|
});
|
|
162
163
|
}
|
|
164
|
+
#bearer(security) {
|
|
165
|
+
if (!Array.isArray(security) || security.length === 0)
|
|
166
|
+
return undefined;
|
|
167
|
+
// An empty requirement makes credentials optional.
|
|
168
|
+
if (security.some((req) => Object.keys(req ?? {}).length === 0))
|
|
169
|
+
return undefined;
|
|
170
|
+
const schemes = this.doc.components?.securitySchemes ?? {};
|
|
171
|
+
for (const req of security) {
|
|
172
|
+
for (const [name, scopes] of Object.entries(req)) {
|
|
173
|
+
const s = this.#deref(schemes[name]);
|
|
174
|
+
if (s && ((s.type === "http" && String(s.scheme).toLowerCase() === "bearer") || s.type === "oauth2" || s.type === "openIdConnect")) {
|
|
175
|
+
return { scopes: Array.isArray(scopes) ? scopes : [] };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
163
181
|
/** A value that satisfies `schema` (as far as a simple walk can): examples, defaults, enums, then types. */
|
|
164
182
|
#sample(schema, depth = 0) {
|
|
165
183
|
const s = this.#deref(schema);
|
|
@@ -345,3 +363,7 @@ function nullableToType(node) {
|
|
|
345
363
|
}
|
|
346
364
|
return node;
|
|
347
365
|
}
|
|
366
|
+
/** Where a spec fetched from the app (`openapi.fromApp`) is kept for the coverage report. */
|
|
367
|
+
export function appSpecFile(coverageDir) {
|
|
368
|
+
return `${coverageDir}.spec.json`;
|
|
369
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface Query {
|
|
2
|
+
sql: string;
|
|
3
|
+
/** Milliseconds since the log started. */
|
|
4
|
+
at: number;
|
|
5
|
+
}
|
|
6
|
+
/** A list of queries, with helpers for spotting N+1 patterns. */
|
|
7
|
+
export declare class QueryList extends Array<Query> {
|
|
8
|
+
/** Statements grouped by shape (literals and parameters replaced by `?`), most frequent first. */
|
|
9
|
+
shapes(): {
|
|
10
|
+
sql: string;
|
|
11
|
+
count: number;
|
|
12
|
+
}[];
|
|
13
|
+
/** Without transaction control and session statements (`BEGIN`, `COMMIT`, `SAVEPOINT`, `SET`, …), which some drivers add around every query. */
|
|
14
|
+
withoutTransactions(): QueryList;
|
|
15
|
+
/** Shapes run at least `min` times (default 3): the usual signature of an N+1. */
|
|
16
|
+
repeated(min?: number): {
|
|
17
|
+
sql: string;
|
|
18
|
+
count: number;
|
|
19
|
+
}[];
|
|
20
|
+
}
|
|
21
|
+
export declare function queryList(queries: Query[]): QueryList;
|
|
22
|
+
/** `SELECT * FROM t WHERE id = 7 AND name = 'x'` → `SELECT * FROM t WHERE id = ? AND name = ?`. */
|
|
23
|
+
export declare function normalizeSql(sql: string): string;
|
|
24
|
+
type Engine = "postgres" | "mysql";
|
|
25
|
+
/**
|
|
26
|
+
* A TCP proxy between the app and its database that reads the wire protocol
|
|
27
|
+
* and records every statement the app runs, whatever its language or driver.
|
|
28
|
+
* Postgres: simple queries and extended-protocol executions. MySQL: COM_QUERY
|
|
29
|
+
* and prepared-statement executions. Connections that switch to TLS are
|
|
30
|
+
* forwarded but not read.
|
|
31
|
+
*/
|
|
32
|
+
export declare class QueryLog {
|
|
33
|
+
#private;
|
|
34
|
+
private readonly server;
|
|
35
|
+
readonly port: number;
|
|
36
|
+
private constructor();
|
|
37
|
+
static start(engine: Engine, upstream: {
|
|
38
|
+
host: string;
|
|
39
|
+
port: number;
|
|
40
|
+
}): Promise<QueryLog>;
|
|
41
|
+
/** `url` with its host and port pointing at the proxy. */
|
|
42
|
+
proxyUrl(url: string): string;
|
|
43
|
+
/** Queries since the last `reset()`, or since `mark` (an index from `mark()`). */
|
|
44
|
+
queries(since?: number): QueryList;
|
|
45
|
+
mark(): number;
|
|
46
|
+
reset(): void;
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
const TRANSACTION = /^\s*(BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE|SET|SHOW|DISCARD|DEALLOCATE)\b/i;
|
|
3
|
+
/** A list of queries, with helpers for spotting N+1 patterns. */
|
|
4
|
+
export class QueryList extends Array {
|
|
5
|
+
/** Statements grouped by shape (literals and parameters replaced by `?`), most frequent first. */
|
|
6
|
+
shapes() {
|
|
7
|
+
const counts = new Map();
|
|
8
|
+
for (const q of this) {
|
|
9
|
+
const shape = normalizeSql(q.sql);
|
|
10
|
+
counts.set(shape, (counts.get(shape) ?? 0) + 1);
|
|
11
|
+
}
|
|
12
|
+
return [...counts].map(([sql, count]) => ({ sql, count })).sort((a, b) => b.count - a.count);
|
|
13
|
+
}
|
|
14
|
+
/** Without transaction control and session statements (`BEGIN`, `COMMIT`, `SAVEPOINT`, `SET`, …), which some drivers add around every query. */
|
|
15
|
+
withoutTransactions() {
|
|
16
|
+
return queryList(this.filter((q) => !TRANSACTION.test(q.sql)));
|
|
17
|
+
}
|
|
18
|
+
/** Shapes run at least `min` times (default 3): the usual signature of an N+1. */
|
|
19
|
+
repeated(min = 3) {
|
|
20
|
+
return this.shapes().filter((s) => s.count >= min);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function queryList(queries) {
|
|
24
|
+
const list = new QueryList();
|
|
25
|
+
list.push(...queries);
|
|
26
|
+
return list;
|
|
27
|
+
}
|
|
28
|
+
/** `SELECT * FROM t WHERE id = 7 AND name = 'x'` → `SELECT * FROM t WHERE id = ? AND name = ?`. */
|
|
29
|
+
export function normalizeSql(sql) {
|
|
30
|
+
return sql
|
|
31
|
+
.replace(/\s+/g, " ")
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/'(?:[^']|'')*'/g, "?")
|
|
34
|
+
.replace(/\$\d+/g, "?")
|
|
35
|
+
.replace(/(?<![\w"`.])-?\d+(\.\d+)?\b/g, "?")
|
|
36
|
+
.replace(/\(\s*\?(\s*,\s*\?)+\s*\)/g, "(?)");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A TCP proxy between the app and its database that reads the wire protocol
|
|
40
|
+
* and records every statement the app runs, whatever its language or driver.
|
|
41
|
+
* Postgres: simple queries and extended-protocol executions. MySQL: COM_QUERY
|
|
42
|
+
* and prepared-statement executions. Connections that switch to TLS are
|
|
43
|
+
* forwarded but not read.
|
|
44
|
+
*/
|
|
45
|
+
export class QueryLog {
|
|
46
|
+
server;
|
|
47
|
+
port;
|
|
48
|
+
#queries = [];
|
|
49
|
+
#started = performance.now();
|
|
50
|
+
#sockets = new Set();
|
|
51
|
+
constructor(server, port) {
|
|
52
|
+
this.server = server;
|
|
53
|
+
this.port = port;
|
|
54
|
+
}
|
|
55
|
+
static async start(engine, upstream) {
|
|
56
|
+
let log;
|
|
57
|
+
const server = net.createServer((client) => log.#connect(engine, client, upstream));
|
|
58
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
59
|
+
log = new QueryLog(server, server.address().port);
|
|
60
|
+
return log;
|
|
61
|
+
}
|
|
62
|
+
/** `url` with its host and port pointing at the proxy. */
|
|
63
|
+
proxyUrl(url) {
|
|
64
|
+
const u = new URL(url);
|
|
65
|
+
u.hostname = "127.0.0.1";
|
|
66
|
+
u.port = String(this.port);
|
|
67
|
+
return u.toString();
|
|
68
|
+
}
|
|
69
|
+
/** Queries since the last `reset()`, or since `mark` (an index from `mark()`). */
|
|
70
|
+
queries(since = 0) {
|
|
71
|
+
return queryList(this.#queries.slice(since));
|
|
72
|
+
}
|
|
73
|
+
mark() {
|
|
74
|
+
return this.#queries.length;
|
|
75
|
+
}
|
|
76
|
+
reset() {
|
|
77
|
+
this.#queries = [];
|
|
78
|
+
}
|
|
79
|
+
#record(sql) {
|
|
80
|
+
this.#queries.push({ sql, at: Math.round(performance.now() - this.#started) });
|
|
81
|
+
}
|
|
82
|
+
#connect(engine, client, upstream) {
|
|
83
|
+
const server = net.connect(upstream.port, upstream.host);
|
|
84
|
+
this.#sockets.add(client).add(server);
|
|
85
|
+
const close = () => {
|
|
86
|
+
client.destroy();
|
|
87
|
+
server.destroy();
|
|
88
|
+
this.#sockets.delete(client);
|
|
89
|
+
this.#sockets.delete(server);
|
|
90
|
+
};
|
|
91
|
+
client.on("error", close).on("close", close);
|
|
92
|
+
server.on("error", close).on("close", close);
|
|
93
|
+
const reader = engine === "postgres" ? new PostgresReader((sql) => this.#record(sql)) : new MysqlReader((sql) => this.#record(sql));
|
|
94
|
+
client.on("data", (chunk) => {
|
|
95
|
+
try {
|
|
96
|
+
reader.fromClient(chunk);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
reader.opaque = true;
|
|
100
|
+
}
|
|
101
|
+
server.write(chunk);
|
|
102
|
+
});
|
|
103
|
+
server.on("data", (chunk) => {
|
|
104
|
+
try {
|
|
105
|
+
reader.fromServer(chunk);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
reader.opaque = true;
|
|
109
|
+
}
|
|
110
|
+
client.write(chunk);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async close() {
|
|
114
|
+
for (const s of this.#sockets)
|
|
115
|
+
s.destroy();
|
|
116
|
+
await new Promise((resolve) => this.server.close(resolve));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const cstring = (buf, start) => {
|
|
120
|
+
const end = buf.indexOf(0, start);
|
|
121
|
+
return [buf.toString("utf8", start, end), end + 1];
|
|
122
|
+
};
|
|
123
|
+
class PostgresReader {
|
|
124
|
+
record;
|
|
125
|
+
opaque = false;
|
|
126
|
+
#buf = Buffer.alloc(0);
|
|
127
|
+
#startup = true;
|
|
128
|
+
#awaitingSsl = false;
|
|
129
|
+
#statements = new Map();
|
|
130
|
+
#portals = new Map();
|
|
131
|
+
constructor(record) {
|
|
132
|
+
this.record = record;
|
|
133
|
+
}
|
|
134
|
+
fromServer(chunk) {
|
|
135
|
+
if (!this.#awaitingSsl)
|
|
136
|
+
return;
|
|
137
|
+
this.#awaitingSsl = false;
|
|
138
|
+
// 'S' (TLS) or 'G' (GSSAPI encryption): the rest is encrypted.
|
|
139
|
+
if (chunk[0] === 0x53 || chunk[0] === 0x47)
|
|
140
|
+
this.opaque = true;
|
|
141
|
+
}
|
|
142
|
+
fromClient(chunk) {
|
|
143
|
+
if (this.opaque)
|
|
144
|
+
return;
|
|
145
|
+
this.#buf = Buffer.concat([this.#buf, chunk]);
|
|
146
|
+
for (;;) {
|
|
147
|
+
if (this.#startup) {
|
|
148
|
+
if (this.#buf.length < 8)
|
|
149
|
+
return;
|
|
150
|
+
const len = this.#buf.readInt32BE(0);
|
|
151
|
+
if (this.#buf.length < len)
|
|
152
|
+
return;
|
|
153
|
+
const code = this.#buf.readInt32BE(4);
|
|
154
|
+
this.#buf = this.#buf.subarray(len);
|
|
155
|
+
if (code === 80877103 || code === 80877104)
|
|
156
|
+
this.#awaitingSsl = true;
|
|
157
|
+
else if (code !== 80877102)
|
|
158
|
+
this.#startup = false;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (this.#buf.length < 5)
|
|
162
|
+
return;
|
|
163
|
+
const type = String.fromCharCode(this.#buf[0]);
|
|
164
|
+
const len = this.#buf.readInt32BE(1);
|
|
165
|
+
if (this.#buf.length < len + 1)
|
|
166
|
+
return;
|
|
167
|
+
const msg = this.#buf.subarray(5, len + 1);
|
|
168
|
+
this.#buf = this.#buf.subarray(len + 1);
|
|
169
|
+
this.#message(type, msg);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
#message(type, msg) {
|
|
173
|
+
if (type === "Q") {
|
|
174
|
+
this.record(cstring(msg, 0)[0]);
|
|
175
|
+
}
|
|
176
|
+
else if (type === "P") {
|
|
177
|
+
const [name, next] = cstring(msg, 0);
|
|
178
|
+
this.#statements.set(name, cstring(msg, next)[0]);
|
|
179
|
+
}
|
|
180
|
+
else if (type === "B") {
|
|
181
|
+
const [portal, next] = cstring(msg, 0);
|
|
182
|
+
this.#portals.set(portal, this.#statements.get(cstring(msg, next)[0]) ?? "");
|
|
183
|
+
}
|
|
184
|
+
else if (type === "E") {
|
|
185
|
+
const sql = this.#portals.get(cstring(msg, 0)[0]);
|
|
186
|
+
if (sql)
|
|
187
|
+
this.record(sql);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const CLIENT_QUERY_ATTRIBUTES = 1 << 27;
|
|
192
|
+
class MysqlReader {
|
|
193
|
+
record;
|
|
194
|
+
opaque = false;
|
|
195
|
+
#client = Buffer.alloc(0);
|
|
196
|
+
#server = Buffer.alloc(0);
|
|
197
|
+
#prepares = [];
|
|
198
|
+
#attributes = false;
|
|
199
|
+
#statements = new Map();
|
|
200
|
+
constructor(record) {
|
|
201
|
+
this.record = record;
|
|
202
|
+
}
|
|
203
|
+
#read(buf, each) {
|
|
204
|
+
while (buf.length >= 4) {
|
|
205
|
+
const len = buf.readUIntLE(0, 3);
|
|
206
|
+
if (buf.length < len + 4)
|
|
207
|
+
break;
|
|
208
|
+
each(buf[3], buf.subarray(4, len + 4));
|
|
209
|
+
buf = buf.subarray(len + 4);
|
|
210
|
+
}
|
|
211
|
+
return Buffer.from(buf);
|
|
212
|
+
}
|
|
213
|
+
fromClient(chunk) {
|
|
214
|
+
if (this.opaque)
|
|
215
|
+
return;
|
|
216
|
+
this.#client = this.#read(Buffer.concat([this.#client, chunk]), (seq, p) => {
|
|
217
|
+
// The handshake response asking for TLS is a short packet with CLIENT_SSL set.
|
|
218
|
+
if (seq === 1 && p.length === 32 && (p.readUInt32LE(0) & 0x800) !== 0)
|
|
219
|
+
this.opaque = true;
|
|
220
|
+
if (seq === 1 && p.length >= 4)
|
|
221
|
+
this.#attributes = (p.readUInt32LE(0) & CLIENT_QUERY_ATTRIBUTES) !== 0;
|
|
222
|
+
// Commands start a new sequence; handshake and auth packets don't.
|
|
223
|
+
if (seq !== 0 || p.length === 0)
|
|
224
|
+
return;
|
|
225
|
+
if (p[0] === 0x03)
|
|
226
|
+
this.record(this.#queryText(p));
|
|
227
|
+
else if (p[0] === 0x16)
|
|
228
|
+
this.#prepares.push(p.toString("utf8", 1));
|
|
229
|
+
else if (p[0] === 0x17 && p.length >= 5) {
|
|
230
|
+
const sql = this.#statements.get(p.readUInt32LE(1));
|
|
231
|
+
if (sql)
|
|
232
|
+
this.record(sql);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** COM_QUERY text; with query attributes it follows a parameter count, a set count and the attributes. */
|
|
237
|
+
#queryText(p) {
|
|
238
|
+
if (!this.#attributes)
|
|
239
|
+
return p.toString("utf8", 1);
|
|
240
|
+
const count = p[1];
|
|
241
|
+
// Drivers send no attributes (count 0, one set); anything else is read past heuristically.
|
|
242
|
+
if (count === 0)
|
|
243
|
+
return p.toString("utf8", 3);
|
|
244
|
+
const text = p.toString("utf8", 3);
|
|
245
|
+
const start = text.search(/\b(SELECT|INSERT|UPDATE|DELETE|WITH|REPLACE|CALL|SET|BEGIN|COMMIT|ROLLBACK|START)\b/i);
|
|
246
|
+
return start >= 0 ? text.slice(start) : text;
|
|
247
|
+
}
|
|
248
|
+
fromServer(chunk) {
|
|
249
|
+
if (this.opaque || this.#prepares.length === 0) {
|
|
250
|
+
this.#server = Buffer.alloc(0);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.#server = this.#read(Buffer.concat([this.#server, chunk]), (seq, p) => {
|
|
254
|
+
if (seq !== 1 || this.#prepares.length === 0)
|
|
255
|
+
return;
|
|
256
|
+
const sql = this.#prepares.shift();
|
|
257
|
+
if (p[0] === 0x00 && p.length >= 5)
|
|
258
|
+
this.#statements.set(p.readUInt32LE(1), sql);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { App } from "./app.js";
|
|
2
|
+
import { Issuer } from "./auth.js";
|
|
2
3
|
import type { ResolvedOptions } from "./config.js";
|
|
3
4
|
import { Dependency } from "./containers.js";
|
|
4
5
|
import { Db } from "./db.js";
|
|
5
6
|
import { HttpClient } from "./http.js";
|
|
7
|
+
import { Interceptor } from "./intercept.js";
|
|
6
8
|
import { Mailbox } from "./mail.js";
|
|
9
|
+
import { NeonEndpoint } from "./neon.js";
|
|
10
|
+
import { QueryLog } from "./query-log.js";
|
|
7
11
|
import { Stub } from "./stub.js";
|
|
8
12
|
import { type MaskOptions, type Trace } from "./trace.js";
|
|
9
13
|
export interface ScenarioContext {
|
|
@@ -22,6 +26,8 @@ export interface ScenarioContext {
|
|
|
22
26
|
trace: (opts?: MaskOptions) => Promise<Trace>;
|
|
23
27
|
/** Mail the app sent during the scenario. Needs `mail: true` in the config. */
|
|
24
28
|
mail: Mailbox;
|
|
29
|
+
/** The OpenID Connect issuer the app trusts: `auth.token(claims)`. Needs `auth` in the config. */
|
|
30
|
+
auth: Issuer;
|
|
25
31
|
}
|
|
26
32
|
/** Everything one test file needs: its own database, stub servers and app process. */
|
|
27
33
|
export declare class Runtime {
|
|
@@ -38,6 +44,10 @@ export declare class Runtime {
|
|
|
38
44
|
private readonly recordDir?;
|
|
39
45
|
readonly containers: Map<string, Dependency>;
|
|
40
46
|
readonly mailbox?: Mailbox | undefined;
|
|
47
|
+
readonly issuer?: Issuer | undefined;
|
|
48
|
+
readonly queryLog?: QueryLog | undefined;
|
|
49
|
+
readonly interceptor?: Interceptor | undefined;
|
|
50
|
+
readonly neon?: NeonEndpoint | undefined;
|
|
41
51
|
private constructor();
|
|
42
52
|
get http(): HttpClient;
|
|
43
53
|
static start(opts: ResolvedOptions, shared: {
|
|
@@ -53,5 +63,13 @@ export declare class Runtime {
|
|
|
53
63
|
afterScenario(): Promise<void>;
|
|
54
64
|
/** What happened during the current scenario, printed when it fails. */
|
|
55
65
|
diagnostics(): Promise<string>;
|
|
66
|
+
/** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
|
|
67
|
+
flush(): Promise<void>;
|
|
56
68
|
stop(): Promise<void>;
|
|
57
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
72
|
+
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
73
|
+
*/
|
|
74
|
+
export declare function connectionVars(engine: string, url: string, sqlitePath?: string): Record<string, string>;
|
|
75
|
+
export declare function blockedHint(hosts: string[]): string;
|