slicetest 0.6.1 → 0.7.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.
@@ -5,10 +5,12 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { annotation, appendSummary, failureAnnotations, failureSummary, onGitHub, repoPath, yamlFailures } from "./ci.js";
8
+ import { interpolate } from "./app.js";
9
+ import { connectionVars } from "./connection.js";
8
10
  import { configureContainerRuntime } from "./container-runtime.js";
9
11
  import { engineFor } from "./drivers/index.js";
10
12
  import { coverageCacheFile } from "./gen.js";
11
- import { appSpecFile, formatCoverage, OpenApiSpec } from "./openapi.js";
13
+ import { appSpecFile, formatCoverage, formatUsage, OpenApiSpec } from "./openapi.js";
12
14
  import { mergeRecordings } from "./recording.js";
13
15
  import "./provided.js";
14
16
  const exec = promisify(execFile);
@@ -27,7 +29,9 @@ export default async function setup(project) {
27
29
  const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
28
30
  // On GitHub Actions, failed YAML steps are collected here and turned into annotations at the end.
29
31
  const ciDir = onGitHub() ? await mkdtemp(path.join(os.tmpdir(), "slicetest-ci-")) : undefined;
30
- project.provide("slicetestDb", { adminUrl: database?.adminUrl ?? "", template: database?.template ?? "", prefix: database?.prefix ?? "", coverageDir, recordDir, ciDir });
32
+ // Operations the app called on providers whose spec a stub has, reported at the end.
33
+ const usageDir = Object.keys(opts.openapi.stubs).length ? await mkdtemp(path.join(os.tmpdir(), "slicetest-usage-")) : undefined;
34
+ project.provide("slicetestDb", { adminUrl: database?.adminUrl ?? "", template: database?.template ?? "", prefix: database?.prefix ?? "", coverageDir, recordDir, ciDir, usageDir });
31
35
  return async () => {
32
36
  try {
33
37
  await database?.teardown();
@@ -35,6 +39,8 @@ export default async function setup(project) {
35
39
  finally {
36
40
  if (coverageDir)
37
41
  await reportCoverage(opts, coverageDir);
42
+ if (usageDir)
43
+ await reportUsage(opts, usageDir);
38
44
  if (recordDir)
39
45
  await saveRecordings(opts, recordDir);
40
46
  if (ciDir)
@@ -129,6 +135,36 @@ async function reportCoverage(opts, dir) {
129
135
  await rm(appSpecFile(dir), { force: true });
130
136
  }
131
137
  }
138
+ async function reportUsage(opts, dir) {
139
+ try {
140
+ const used = new Map();
141
+ for (const file of await readdir(dir)) {
142
+ for (const [name, keys] of Object.entries(JSON.parse(await readFile(path.join(dir, file), "utf8")))) {
143
+ const set = used.get(name) ?? new Set();
144
+ for (const k of keys)
145
+ set.add(k);
146
+ used.set(name, set);
147
+ }
148
+ }
149
+ for (const [name, file] of Object.entries(opts.openapi.stubs)) {
150
+ const keys = used.get(name);
151
+ if (!keys)
152
+ continue;
153
+ const report = formatUsage(name, await OpenApiSpec.load(path.resolve(opts.root, file), file), keys);
154
+ console.log(`\n${report.text}\n`);
155
+ await appendSummary(report.markdown);
156
+ if (onGitHub() && report.deprecated.length) {
157
+ console.log(annotation("warning", `The app calls operations ${file} marks deprecated: ${report.deprecated.join(", ")}`, { file: repoPath(path.resolve(opts.root, file)), title: `slicetest: deprecated ${name} API` }));
158
+ }
159
+ }
160
+ }
161
+ catch (e) {
162
+ console.error(`slicetest: couldn't report API usage: ${e.message}`);
163
+ }
164
+ finally {
165
+ await rm(dir, { recursive: true, force: true });
166
+ }
167
+ }
132
168
  async function reportToGitHub(dir) {
133
169
  try {
134
170
  const failures = await yamlFailures(dir);
@@ -268,8 +304,13 @@ async function applyMigrations(opts, engine, url, m) {
268
304
  }
269
305
  return "";
270
306
  }
271
- // Through the platform shell (sh or cmd.exe), like app.command.
272
- return run(m.command, [], opts.root, { DATABASE_URL: url }, true);
307
+ // Through the platform shell (sh or cmd.exe), like app.command, with the same {{db.*}} placeholders.
308
+ const vars = { "db.url": url };
309
+ if (engine.name === "sqlite")
310
+ vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
311
+ Object.assign(vars, connectionVars(engine.name, url, vars["db.path"]));
312
+ const env = Object.fromEntries(Object.entries(m.env ?? {}).map(([k, v]) => [k, interpolate(v, vars, `db.migrate.env.${k}`)]));
313
+ return run(interpolate(m.command, vars, "db.migrate.command"), [], opts.root, { DATABASE_URL: url, ...env }, true);
273
314
  }
274
315
  /**
275
316
  * Atlas wants `file://<path>` with forward slashes. Absolute paths (including
@@ -0,0 +1,34 @@
1
+ /**
2
+ * GraphQL over HTTP, as clients send it (Apollo, urql, graphql-request, gql, …):
3
+ * a POST with `{ query, variables, operationName }`, or a GET with those as query
4
+ * parameters. Stubs match calls by operation, and the app's own GraphQL endpoint
5
+ * is called with `http.graphql()`.
6
+ */
7
+ export interface GraphQLCall {
8
+ /** `operationName` from the request, else the name in the document (`query GetUser { … }`). Undefined for an anonymous operation. */
9
+ operation?: string;
10
+ type: "query" | "mutation" | "subscription";
11
+ query: string;
12
+ variables: Record<string, unknown>;
13
+ }
14
+ /** The operations a document defines, in order. Comments and strings don't count. */
15
+ export declare function operations(document: string): {
16
+ type: GraphQLCall["type"];
17
+ name?: string;
18
+ }[];
19
+ /** The GraphQL request in an HTTP call, or undefined when it isn't one. */
20
+ export declare function graphqlOf(call: {
21
+ method: string;
22
+ query: URLSearchParams;
23
+ json: any;
24
+ }): GraphQLCall | undefined;
25
+ /** A GraphQL error list from messages or full error objects. */
26
+ export declare function graphqlErrors(errors: (string | {
27
+ message: string;
28
+ [k: string]: unknown;
29
+ })[]): {
30
+ [k: string]: unknown;
31
+ message: string;
32
+ }[];
33
+ /** `GraphQL mutation CreateIssue`, for failure messages. */
34
+ export declare function describeGraphQL(g: GraphQLCall): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * GraphQL over HTTP, as clients send it (Apollo, urql, graphql-request, gql, …):
3
+ * a POST with `{ query, variables, operationName }`, or a GET with those as query
4
+ * parameters. Stubs match calls by operation, and the app's own GraphQL endpoint
5
+ * is called with `http.graphql()`.
6
+ */
7
+ const OPERATION = /(?:^|[\s}])(query|mutation|subscription)\b\s*([_A-Za-z][_0-9A-Za-z]*)?/g;
8
+ /** The operations a document defines, in order. Comments and strings don't count. */
9
+ export function operations(document) {
10
+ const code = document.replace(/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|#[^\n]*/g, " ");
11
+ const found = [];
12
+ for (const m of code.matchAll(OPERATION))
13
+ found.push({ type: m[1], name: m[2] });
14
+ // `{ viewer { id } }` without a keyword is an anonymous query.
15
+ if (found.length === 0 && /^\s*\{/.test(code))
16
+ found.push({ type: "query" });
17
+ return found;
18
+ }
19
+ /** The GraphQL request in an HTTP call, or undefined when it isn't one. */
20
+ export function graphqlOf(call) {
21
+ let doc, name, variables;
22
+ if (call.method === "GET") {
23
+ doc = call.query.get("query") ?? undefined;
24
+ name = call.query.get("operationName") ?? undefined;
25
+ const v = call.query.get("variables");
26
+ try {
27
+ variables = v ? JSON.parse(v) : undefined;
28
+ }
29
+ catch {
30
+ variables = undefined;
31
+ }
32
+ }
33
+ else if (call.json && typeof call.json === "object" && !Array.isArray(call.json)) {
34
+ ({ query: doc, operationName: name, variables } = call.json);
35
+ }
36
+ if (typeof doc !== "string")
37
+ return undefined;
38
+ const ops = operations(doc);
39
+ const op = typeof name === "string" && name ? (ops.find((o) => o.name === name) ?? { type: "query", name }) : ops[0];
40
+ if (!op)
41
+ return undefined;
42
+ return {
43
+ operation: typeof name === "string" && name ? name : op.name,
44
+ type: op.type,
45
+ query: doc,
46
+ variables: variables && typeof variables === "object" ? variables : {},
47
+ };
48
+ }
49
+ /** A GraphQL error list from messages or full error objects. */
50
+ export function graphqlErrors(errors) {
51
+ return errors.map((e) => (typeof e === "string" ? { message: e } : e));
52
+ }
53
+ /** `GraphQL mutation CreateIssue`, for failure messages. */
54
+ export function describeGraphQL(g) {
55
+ return `GraphQL ${g.type} ${g.operation ?? "(anonymous)"}`;
56
+ }
package/dist/har.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { type Recording } from "./recording.js";
2
+ /**
3
+ * `slicetest import session.har`: turn a HAR file (saved from the browser's
4
+ * network panel, Charles, mitmproxy, Proxyman, Postman, …) into recordings a
5
+ * stub replays, without running the app against the real service first.
6
+ */
7
+ interface HarEntry {
8
+ request: {
9
+ method: string;
10
+ url: string;
11
+ postData?: {
12
+ mimeType?: string;
13
+ text?: string;
14
+ };
15
+ };
16
+ response: {
17
+ status: number;
18
+ headers?: {
19
+ name: string;
20
+ value: string;
21
+ }[];
22
+ content?: {
23
+ mimeType?: string;
24
+ text?: string;
25
+ encoding?: string;
26
+ };
27
+ };
28
+ }
29
+ export interface HarTarget {
30
+ /** Stub name. */
31
+ name: string;
32
+ /** The real service's base URL; entries under it become recordings, with this prefix removed from their path. */
33
+ upstream: string;
34
+ /** Recordings file, absolute. */
35
+ file: string;
36
+ }
37
+ export declare function harEntries(har: unknown, file?: string): HarEntry[];
38
+ /** The entry as a recording, if it is under `upstream` and replayable; otherwise why not. */
39
+ export declare function toRecording(entry: HarEntry, upstream: string): Recording | "elsewhere" | "skipped";
40
+ export declare function importHar(harFile: string, targets: HarTarget[]): Promise<{
41
+ written: {
42
+ name: string;
43
+ file: string;
44
+ count: number;
45
+ }[];
46
+ skipped: number;
47
+ others: [string, number][];
48
+ }>;
49
+ export {};
package/dist/har.js ADDED
@@ -0,0 +1,107 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { mergeRecordings } from "./recording.js";
4
+ const KEPT_HEADERS = ["content-type", "location", "retry-after", "link", "etag"];
5
+ const TEXTUAL = /json|text|xml|javascript|x-www-form-urlencoded|graphql/i;
6
+ export function harEntries(har, file = "the HAR file") {
7
+ const entries = har?.log?.entries;
8
+ if (!Array.isArray(entries))
9
+ throw new Error(`slicetest import: ${file} is not a HAR file (no log.entries)`);
10
+ return entries;
11
+ }
12
+ /** The entry as a recording, if it is under `upstream` and replayable; otherwise why not. */
13
+ export function toRecording(entry, upstream) {
14
+ const base = new URL(upstream);
15
+ let url;
16
+ try {
17
+ url = new URL(entry.request.url);
18
+ }
19
+ catch {
20
+ return "skipped";
21
+ }
22
+ const prefix = base.pathname.replace(/\/$/, "");
23
+ if (url.host !== base.host || !(url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)))
24
+ return "elsewhere";
25
+ const method = entry.request.method.toUpperCase();
26
+ // Preflights are the browser's; aborted and blocked requests (status 0) have no answer to replay.
27
+ if (method === "OPTIONS" || !entry.response || !entry.response.status)
28
+ return "skipped";
29
+ const query = Object.fromEntries([...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)));
30
+ const sent = entry.request.postData?.text;
31
+ const json = parse(sent);
32
+ const request = {
33
+ method,
34
+ path: url.pathname.slice(prefix.length) || "/",
35
+ ...(Object.keys(query).length ? { query } : {}),
36
+ ...(json !== undefined ? { json } : sent ? { body: sent } : {}),
37
+ };
38
+ const content = entry.response.content ?? {};
39
+ let text = content.text ?? "";
40
+ if (content.encoding === "base64" && text) {
41
+ if (!TEXTUAL.test(content.mimeType ?? ""))
42
+ return "skipped";
43
+ text = Buffer.from(text, "base64").toString("utf8");
44
+ }
45
+ const headers = Object.fromEntries((entry.response.headers ?? []).flatMap((h) => (KEPT_HEADERS.includes(h.name.toLowerCase()) ? [[h.name.toLowerCase(), h.value]] : [])));
46
+ const body = parse(text);
47
+ return {
48
+ request,
49
+ response: {
50
+ status: entry.response.status,
51
+ ...(Object.keys(headers).length ? { headers } : {}),
52
+ ...(body !== undefined ? { json: body } : text ? { body: text } : {}),
53
+ },
54
+ };
55
+ }
56
+ export async function importHar(harFile, targets) {
57
+ const text = await readFile(harFile, "utf8");
58
+ let har;
59
+ try {
60
+ har = JSON.parse(text);
61
+ }
62
+ catch (e) {
63
+ throw new Error(`slicetest import: ${harFile} is not JSON: ${e.message}`);
64
+ }
65
+ const entries = harEntries(har, harFile);
66
+ const written = [];
67
+ const claimed = new Set();
68
+ let skipped = 0;
69
+ for (const t of targets) {
70
+ const recordings = [];
71
+ for (const e of entries) {
72
+ const r = toRecording(e, t.upstream);
73
+ if (r === "elsewhere")
74
+ continue;
75
+ claimed.add(e);
76
+ if (r === "skipped")
77
+ skipped++;
78
+ else
79
+ recordings.push(r);
80
+ }
81
+ if (recordings.length === 0)
82
+ continue;
83
+ await mergeRecordings(t.file, t.upstream, recordings, `Imported by slicetest from ${path.basename(harFile)} (${t.upstream})`);
84
+ written.push({ name: t.name, file: t.file, count: recordings.length });
85
+ }
86
+ const others = new Map();
87
+ for (const e of entries) {
88
+ if (claimed.has(e))
89
+ continue;
90
+ try {
91
+ const host = new URL(e.request.url).origin;
92
+ others.set(host, (others.get(host) ?? 0) + 1);
93
+ }
94
+ catch { }
95
+ }
96
+ return { written, skipped, others: [...others].sort((a, b) => b[1] - a[1]) };
97
+ }
98
+ function parse(text) {
99
+ if (!text)
100
+ return undefined;
101
+ try {
102
+ return JSON.parse(text);
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
package/dist/http.d.ts CHANGED
@@ -56,6 +56,16 @@ export declare class HttpClient {
56
56
  * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
57
57
  */
58
58
  webhook(path: string, payload: unknown, opts: WebhookOptions): Promise<HttpResponse>;
59
+ /**
60
+ * Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
61
+ * `http.graphql(CREATE_POLL, { title: "Tea?" })`. Posts `{ query, variables, operationName }` to
62
+ * `/graphql` (`path` for another endpoint). Pair with `toHaveGraphQLData()`, since GraphQL
63
+ * servers answer errors with status 200.
64
+ */
65
+ graphql(query: string, variables?: Record<string, unknown>, opts?: RequestOptions & {
66
+ path?: string;
67
+ operationName?: string;
68
+ }): Promise<HttpResponse>;
59
69
  /**
60
70
  * Run `send` `n` times at once and wait for every response, to provoke races
61
71
  * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
package/dist/http.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { formRequest } from "./form.js";
2
2
  import { lookupHost } from "./intercept.js";
3
+ import { timeline } from "./timeline.js";
3
4
  import { signWebhook, webhookBody } from "./webhook.js";
4
5
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
5
6
  class Session {
@@ -86,6 +87,16 @@ export class HttpClient {
86
87
  const { body, type } = webhookBody(payload);
87
88
  return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, opts), ...opts.headers } });
88
89
  }
90
+ /**
91
+ * Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
92
+ * `http.graphql(CREATE_POLL, { title: "Tea?" })`. Posts `{ query, variables, operationName }` to
93
+ * `/graphql` (`path` for another endpoint). Pair with `toHaveGraphQLData()`, since GraphQL
94
+ * servers answer errors with status 200.
95
+ */
96
+ graphql(query, variables, opts = {}) {
97
+ const { path = "/graphql", operationName, ...request } = opts;
98
+ return this.request("POST", path, { query, ...(variables ? { variables } : {}), ...(operationName ? { operationName } : {}) }, request);
99
+ }
89
100
  /**
90
101
  * Run `send` `n` times at once and wait for every response, to provoke races
91
102
  * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
@@ -164,7 +175,9 @@ export class HttpClient {
164
175
  }
165
176
  catch (e) {
166
177
  const cause = e.cause?.code;
167
- this.#record({ method, url: url.pathname + url.search, status: 0, headers: new Headers(), text: `${e}${cause ? ` (${cause})` : ""}`, json: undefined, durationMs: Math.round(performance.now() - started) });
178
+ const failed = { method, url: url.pathname + url.search, status: 0, headers: new Headers(), text: `${e}${cause ? ` (${cause})` : ""}`, json: undefined, durationMs: Math.round(performance.now() - started) };
179
+ timeline.set(failed, { start: started, end: performance.now() });
180
+ this.#record(failed);
168
181
  throw e;
169
182
  }
170
183
  if (!stub)
@@ -185,6 +198,7 @@ export class HttpClient {
185
198
  json,
186
199
  durationMs: Math.round(performance.now() - started),
187
200
  };
201
+ timeline.set(out, { start: started, end: performance.now() });
188
202
  this.#record(out);
189
203
  // Listeners (contract checks, coverage) are about the app's responses only.
190
204
  if (!stub)
package/dist/index.d.ts CHANGED
@@ -3,7 +3,8 @@ export { mask } from "./trace.js";
3
3
  export type { Trace, MaskOptions } from "./trace.js";
4
4
  export type { ScenarioContext } from "./runtime.js";
5
5
  export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
6
- export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, ChaosOptions, ServerSentEvent } from "./stub.js";
6
+ export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, GraphQLRouteBuilder, ChaosOptions, ServerSentEvent } from "./stub.js";
7
+ export type { GraphQLCall } from "./graphql.js";
7
8
  export { sse } from "./stub.js";
8
9
  export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
9
10
  export type { SubmitOptions } from "./form.js";