@substrio/cli 0.0.0-stage → 0.1.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/LICENSE +177 -0
- package/README.md +66 -2
- package/bin/substrio.mjs +91 -0
- package/package.json +44 -4
- package/src/api.mjs +107 -0
- package/src/apply.mjs +204 -0
- package/src/blobs.mjs +102 -0
- package/src/cli.mjs +405 -0
- package/src/collections.mjs +701 -0
- package/src/command-table.mjs +436 -0
- package/src/commands/bundle.mjs +369 -0
- package/src/commands/count.mjs +39 -0
- package/src/commands/deploy.mjs +488 -0
- package/src/commands/dev.mjs +246 -0
- package/src/commands/env.mjs +115 -0
- package/src/commands/files.mjs +172 -0
- package/src/commands/git.mjs +195 -0
- package/src/commands/groups-runtime.mjs +227 -0
- package/src/commands/groups.mjs +417 -0
- package/src/commands/history.mjs +374 -0
- package/src/commands/import.mjs +506 -0
- package/src/commands/init.mjs +175 -0
- package/src/commands/key.mjs +172 -0
- package/src/commands/login.mjs +167 -0
- package/src/commands/logs.mjs +285 -0
- package/src/commands/mcp-config.mjs +102 -0
- package/src/commands/op.mjs +265 -0
- package/src/commands/open.mjs +35 -0
- package/src/commands/push.mjs +199 -0
- package/src/commands/signup.mjs +164 -0
- package/src/commands/site.mjs +129 -0
- package/src/commands/skills.mjs +177 -0
- package/src/commands/status.mjs +78 -0
- package/src/commands/tenants.mjs +172 -0
- package/src/commands/test.mjs +167 -0
- package/src/commands/tools.mjs +63 -0
- package/src/commands/versions.mjs +293 -0
- package/src/config.mjs +569 -0
- package/src/documents.mjs +139 -0
- package/src/function-build.mjs +273 -0
- package/src/identity.mjs +248 -0
- package/src/jsonfile.mjs +22 -0
- package/src/live-move.mjs +267 -0
- package/src/local/args.mjs +58 -0
- package/src/local/reload.mjs +408 -0
- package/src/local/server.mjs +291 -0
- package/src/local/watch.mjs +175 -0
- package/src/manifest.mjs +591 -0
- package/src/ops.mjs +265 -0
- package/src/readers.mjs +416 -0
- package/src/sdk.mjs +26 -0
- package/src/settings.mjs +117 -0
- package/src/testing/app.mjs +507 -0
- package/src/testing/env.mjs +35 -0
- package/src/testing/index.mjs +11 -0
- package/src/testing/types.mjs +55 -0
package/src/sdk.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the CLI loads the SDK's generated operation client.
|
|
3
|
+
*
|
|
4
|
+
* An installed CLI resolves it by package name (@substrio/sdk, a declared
|
|
5
|
+
* dependency). A substrio checkout runs the CLI with no install step, so when the
|
|
6
|
+
* package is not found the client loads from the sibling packages/substrio-sdk.
|
|
7
|
+
* Only "module not found" falls back; any other load error is rethrown.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const PACKAGE = '@substrio/sdk/operation-client';
|
|
11
|
+
const CHECKOUT = new URL('../../substrio-sdk/substrio_operation_client.mjs', import.meta.url).href;
|
|
12
|
+
|
|
13
|
+
async function load() {
|
|
14
|
+
try {
|
|
15
|
+
return { client: await import(PACKAGE), source: 'package' };
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err;
|
|
18
|
+
return { client: await import(CHECKOUT), source: 'checkout' };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { client, source } = await load();
|
|
23
|
+
|
|
24
|
+
/** Where the client came from: 'package' (installed) or 'checkout'. */
|
|
25
|
+
export const sdkSource = source;
|
|
26
|
+
export const { buildRequest, catalogRoute, pickRoute, routeParams } = client;
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: the app's settings substrio.toml declares, carried in the version
|
|
3
|
+
* `substrio push` makes and applied when that version goes live (the sandbox on
|
|
4
|
+
* push, the app on promote, the older ones again on a rollback):
|
|
5
|
+
*
|
|
6
|
+
* plans_file = "plans.json" # version member "plans", as POST /v1/_plans takes
|
|
7
|
+
* [auth] # version member "auth", as PUT /v1/_auth/settings takes
|
|
8
|
+
* email_function = "send_auth_mail"
|
|
9
|
+
* reset_url = "https://app.example/reset?token={token}"
|
|
10
|
+
* verify_url_env = "APP_VERIFY_URL" # a value that differs between environments (the site of a
|
|
11
|
+
* # development deploy and of the live one): the NAME of the
|
|
12
|
+
* # variable that holds it, in place of the value (never both);
|
|
13
|
+
* # a variable that is not set refuses the config
|
|
14
|
+
*
|
|
15
|
+
* The keys a settings table accepts are the properties of its operation's
|
|
16
|
+
* input schema in the server's GET /v1/_openapi (generated from the
|
|
17
|
+
* operation registry), so a new setting needs no change here. A key the
|
|
18
|
+
* schema does not declare fails the push before anything is sent. An absent
|
|
19
|
+
* table or plans_file leaves the target's settings as they are. The
|
|
20
|
+
* functions here do no network.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { PROVIDERS_KEY, unsetMessage, valueOrEnv } from './identity.mjs';
|
|
27
|
+
import { findOperation } from './ops.mjs';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} SettingsTable
|
|
31
|
+
* @property {string} table substrio.toml table name, such as "auth"
|
|
32
|
+
* @property {string} operation registry operation whose input schema it follows
|
|
33
|
+
* @property {string} member the version member that carries it
|
|
34
|
+
* @property {string} label what the CLI calls it in its output
|
|
35
|
+
* @property {string} [apart] a key of the table that is not a setting (another module applies it)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** @type {readonly SettingsTable[]} */
|
|
39
|
+
export const SETTINGS_TABLES = Object.freeze([
|
|
40
|
+
Object.freeze({ table: 'auth', operation: 'auth.settings.set', member: 'auth', label: 'Auth settings', apart: PROVIDERS_KEY }),
|
|
41
|
+
Object.freeze({ table: 'login', operation: 'auth.login.set', member: 'login', label: 'Shared login' }),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const isTable = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
45
|
+
|
|
46
|
+
const FROM_ENV = '_env';
|
|
47
|
+
const plain = (message) => new Error(message);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The settings tables substrio.toml declares, in SETTINGS_TABLES order:
|
|
51
|
+
* [{table, operation, label, values}], each value written in the file or read from the
|
|
52
|
+
* variable its `<key>_env` names (`env`, process.env by default; identity.mjs valueOrEnv).
|
|
53
|
+
* Throws when one is not a table, or a named variable is not set. [auth.providers.NAME]
|
|
54
|
+
* tables are not settings (identity.mjs applies them): `values` leaves them out, and a
|
|
55
|
+
* table that holds nothing else is not declared.
|
|
56
|
+
*/
|
|
57
|
+
export function declaredSettings(config, env = process.env) {
|
|
58
|
+
const declared = [];
|
|
59
|
+
for (const spec of SETTINGS_TABLES) {
|
|
60
|
+
const values = config[spec.table];
|
|
61
|
+
if (values === undefined || values === null) continue;
|
|
62
|
+
if (!isTable(values)) throw new Error(`[${spec.table}] must be a table of settings`);
|
|
63
|
+
const where = `[${spec.table}]`;
|
|
64
|
+
const own = {};
|
|
65
|
+
for (const written of Object.keys(values)) {
|
|
66
|
+
if (written === spec.apart) continue;
|
|
67
|
+
const key = written.endsWith(FROM_ENV) ? written.slice(0, -FROM_ENV.length) : written;
|
|
68
|
+
if (Object.hasOwn(own, key)) continue;
|
|
69
|
+
const value = valueOrEnv(where, values, key, env, plain);
|
|
70
|
+
if (value === null) throw plain(unsetMessage(where, values, key));
|
|
71
|
+
own[key] = value;
|
|
72
|
+
}
|
|
73
|
+
if (Object.keys(own).length === 0 && Object.keys(values).length > 0) continue;
|
|
74
|
+
declared.push({ ...spec, values: own });
|
|
75
|
+
}
|
|
76
|
+
return declared;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Checks each declared table's keys against its operation's input schema in
|
|
81
|
+
* the indexed operations `ops` (ops.mjs indexOperations). Throws naming the
|
|
82
|
+
* first unknown key and the keys the table accepts.
|
|
83
|
+
*/
|
|
84
|
+
export function checkSettings(declared, ops) {
|
|
85
|
+
for (const setting of declared) {
|
|
86
|
+
const routes = findOperation(ops, setting.operation);
|
|
87
|
+
const schema = routes.find((r) => r.bodySchema)?.bodySchema;
|
|
88
|
+
const known = Object.keys(schema?.properties ?? {});
|
|
89
|
+
const unknown = Object.keys(setting.values).find((key) => !known.includes(key));
|
|
90
|
+
if (unknown !== undefined) {
|
|
91
|
+
throw new Error(`[${setting.table}] has unknown key "${unknown}"; it accepts ${known.join(', ') || 'no keys'}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The plans substrio.toml's plans_file names, parsed (the body POST /v1/_plans
|
|
98
|
+
* takes), or null when it names none. Throws when the file is missing or is
|
|
99
|
+
* not JSON.
|
|
100
|
+
*/
|
|
101
|
+
export function declaredPlans(config, cwd) {
|
|
102
|
+
if (!config.plans_file) return null;
|
|
103
|
+
const path = resolve(cwd, config.plans_file);
|
|
104
|
+
if (!existsSync(path)) throw new Error(`plans_file ${config.plans_file} does not exist`);
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new Error(`plans_file ${config.plans_file} is not JSON: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Puts the declared settings and plans into a version body as its members. */
|
|
113
|
+
export function carrySettings(body, declared, plans) {
|
|
114
|
+
for (const setting of declared) body[setting.member] = setting.values;
|
|
115
|
+
if (plans !== null) body.plans = plans;
|
|
116
|
+
return body;
|
|
117
|
+
}
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test harness of `substrio test`: an isolated test tenant per `startApp()`,
|
|
3
|
+
* the app applied to it exactly as `substrio dev` applies it, and typed ways to call
|
|
4
|
+
* its functions, cron schedules and triggers directly, to read and write its
|
|
5
|
+
* data and logs, to stub the outbound fetches its functions make and to move
|
|
6
|
+
* its clock.
|
|
7
|
+
*
|
|
8
|
+
* Everything here runs over a Transport, one function
|
|
9
|
+
* `(operation, input) => {ok, status, data, text}`; the real one is the test
|
|
10
|
+
* tenant's own API client (transportOf), a unit test passes a fake.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { request as httpRequest } from 'node:http';
|
|
16
|
+
|
|
17
|
+
import { errorText } from '../api.mjs';
|
|
18
|
+
import { loadApp, makeLive, publishVersion, readApp } from '../apply.mjs';
|
|
19
|
+
import { applyCollections } from '../collections.mjs';
|
|
20
|
+
import { CliError } from '../cli.mjs';
|
|
21
|
+
import { OpError, callOperation } from '../commands/op.mjs';
|
|
22
|
+
import { followLogs } from '../commands/logs.mjs';
|
|
23
|
+
import { LocalServer } from '../local/server.mjs';
|
|
24
|
+
import { readTestEnv } from './env.mjs';
|
|
25
|
+
import {
|
|
26
|
+
CallOutcome, ClockMode, HttpMethod, Level, PathMatch, RunOutcome, TestError, TestErrorCode, TriggerKind, member,
|
|
27
|
+
} from './types.mjs';
|
|
28
|
+
|
|
29
|
+
/** The Transport of a tenant's API client. A refused name or input is a TestError, a server error is data. */
|
|
30
|
+
export function transportOf(api, ops) {
|
|
31
|
+
return async (name, input) => {
|
|
32
|
+
try {
|
|
33
|
+
return await callOperation(api, ops, name, input);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof OpError) throw new TestError(err.message, TestErrorCode.INVALID_ARGUMENT);
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The data of an answer that must be ok, else the server's typed error as a CliError. */
|
|
42
|
+
function expectOk(result, what) {
|
|
43
|
+
if (result.ok) return result.data;
|
|
44
|
+
throw CliError.fromResponse(`${what}: ${errorText(result.data, result.status)}`, result.data, result.status);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Fixtures
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** The documents of one collection a test starts with. */
|
|
52
|
+
export class Fixture {
|
|
53
|
+
constructor(collection, documents) {
|
|
54
|
+
if (typeof collection !== 'string' || collection === '') {
|
|
55
|
+
throw new TestError('A fixture needs the name of its collection', TestErrorCode.INVALID_FIXTURE);
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(documents) || documents.some((d) => d === null || typeof d !== 'object' || Array.isArray(d))) {
|
|
58
|
+
throw new TestError(`The documents of fixture ${collection} must be an array of objects`, TestErrorCode.INVALID_FIXTURE);
|
|
59
|
+
}
|
|
60
|
+
this.collection = collection;
|
|
61
|
+
this.documents = documents;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const fixture = (collection, documents) => new Fixture(collection, documents);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Calling functions, cron and triggers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** One call of a function: how it ended, its answer, or its typed error. */
|
|
72
|
+
export class Run {
|
|
73
|
+
constructor({ outcome, status, value = null, error = null, durationMs = null }) {
|
|
74
|
+
this.outcome = outcome;
|
|
75
|
+
this.status = status;
|
|
76
|
+
this.value = value; // OK: the function's answer, parsed as JSON when it is
|
|
77
|
+
this.error = error; // ERROR: {code, message, functionError}
|
|
78
|
+
this.durationMs = durationMs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get ok() { return this.outcome === RunOutcome.OK; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runOf(result) {
|
|
85
|
+
if (result.ok) {
|
|
86
|
+
return new Run({ outcome: RunOutcome.OK, status: result.status, value: result.data !== undefined ? result.data : result.text });
|
|
87
|
+
}
|
|
88
|
+
const body = result.data ?? {};
|
|
89
|
+
return new Run({
|
|
90
|
+
outcome: RunOutcome.ERROR,
|
|
91
|
+
status: result.status,
|
|
92
|
+
error: { code: body.code ?? null, message: errorText(result.data, result.status), functionError: body.function_error ?? null },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A deployed function, by a name the app really has. */
|
|
97
|
+
export class FunctionRef {
|
|
98
|
+
constructor(name, transport) {
|
|
99
|
+
this.name = name;
|
|
100
|
+
this.transport = transport;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Calls it with `event` (any JSON); never throws on a function that throws: that is Run.outcome ERROR. */
|
|
104
|
+
async call(event = {}) {
|
|
105
|
+
return runOf(await this.transport('function.invoke', { name: this.name, event }));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The event a collection trigger hands its function (the one the server builds on a write):
|
|
111
|
+
* {trigger: {type, collection, documentId, timestamp, data}}, `data` left out for a delete.
|
|
112
|
+
*/
|
|
113
|
+
export function triggerEvent({ kind, collection, id = '', data = undefined, timestamp }) {
|
|
114
|
+
member(TriggerKind, kind, 'a trigger kind');
|
|
115
|
+
if (typeof collection !== 'string' || collection === '') {
|
|
116
|
+
throw new TestError('A trigger needs its collection', TestErrorCode.INVALID_ARGUMENT);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
trigger: {
|
|
120
|
+
type: kind, collection, documentId: id, timestamp,
|
|
121
|
+
...(kind !== TriggerKind.DELETE && data !== undefined ? { data } : {}),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Data
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/** One collection of the test tenant, over the data operations. */
|
|
131
|
+
export class TestCollection {
|
|
132
|
+
constructor(name, transport) {
|
|
133
|
+
this.name = name;
|
|
134
|
+
this.transport = transport;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Stores `document` under a new id; returns the stored record's _id. */
|
|
138
|
+
async insert(document) {
|
|
139
|
+
const answer = expectOk(await this.transport('data.insert', { collection: this.name, document }), `insert into ${this.name}`);
|
|
140
|
+
return answer._id ?? answer.id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Stores every document of `documents` (data.import): their own _id kept when they name one. */
|
|
144
|
+
async load(documents) {
|
|
145
|
+
const answer = expectOk(await this.transport('data.import', { collection: this.name, documents }), `import into ${this.name}`);
|
|
146
|
+
if (answer.errors?.length) {
|
|
147
|
+
throw new TestError(`import into ${this.name} refused ${answer.errors.length} documents: ${JSON.stringify(answer.errors[0])}`, TestErrorCode.INVALID_FIXTURE);
|
|
148
|
+
}
|
|
149
|
+
return answer.inserted;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The record with `id`, or null. */
|
|
153
|
+
async get(id) {
|
|
154
|
+
const result = await this.transport('data.get', { collection: this.name, id });
|
|
155
|
+
if (result.status === 404) return null;
|
|
156
|
+
return expectOk(result, `get ${this.name}/${id}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The records matching `where` (a data.query filter; every record when none), in `sort` order, at most `limit`. */
|
|
160
|
+
async find(where = {}, { sort, limit } = {}) {
|
|
161
|
+
const answer = expectOk(await this.transport('data.query', {
|
|
162
|
+
collection: this.name, where, ...(sort ? { sort } : {}), ...(limit ? { limit } : {}),
|
|
163
|
+
}), `query ${this.name}`);
|
|
164
|
+
return answer.data ?? answer.results ?? answer.records ?? [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** How many records match `where` (every record when it has no field); 0 for a collection never made. */
|
|
168
|
+
async count(where = {}) {
|
|
169
|
+
// An empty filter is no filter, and query.count's GET route has no `where`
|
|
170
|
+
// query option: an empty one is left out rather than sent.
|
|
171
|
+
const input = Object.keys(where).length > 0 ? { collection: this.name, where } : { collection: this.name };
|
|
172
|
+
const result = await this.transport('query.count', input);
|
|
173
|
+
if (result.status === 404) return 0;
|
|
174
|
+
const answer = expectOk(result, `count ${this.name}`);
|
|
175
|
+
return answer.count;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async delete(id) {
|
|
179
|
+
expectOk(await this.transport('data.delete', { collection: this.name, id }), `delete ${this.name}/${id}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export class TestDb {
|
|
184
|
+
constructor(transport) { this.transport = transport; }
|
|
185
|
+
|
|
186
|
+
collection(name) { return new TestCollection(name, this.transport); }
|
|
187
|
+
|
|
188
|
+
/** Loads fixtures: every Fixture's documents into its collection. */
|
|
189
|
+
async load(fixtures) {
|
|
190
|
+
for (const f of fixtures) {
|
|
191
|
+
if (!(f instanceof Fixture)) throw new TestError('load() takes fixture(collection, documents) values', TestErrorCode.INVALID_FIXTURE);
|
|
192
|
+
await this.collection(f.collection).load(f.documents);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Logs
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
const DEFAULT_WAIT_MS = 10_000;
|
|
202
|
+
|
|
203
|
+
export class TestLogs {
|
|
204
|
+
/**
|
|
205
|
+
* @param {Function} transport
|
|
206
|
+
* @param {(filter: object, onEntry: Function, signal: AbortSignal, onConnected: Function) => Promise<void>} follow
|
|
207
|
+
*/
|
|
208
|
+
constructor(transport, follow) {
|
|
209
|
+
this.transport = transport;
|
|
210
|
+
this.follow = follow;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The log lines now stored, oldest first, of `function` and/or at `level`. */
|
|
214
|
+
async entries({ function: fn, level, requestId, limit } = {}) {
|
|
215
|
+
if (level !== undefined) member(Level, level, 'a log level');
|
|
216
|
+
const answer = expectOk(await this.transport('function.logs', {
|
|
217
|
+
...(fn ? { function: fn } : {}), ...(level ? { level } : {}),
|
|
218
|
+
...(requestId ? { request_id: requestId } : {}), ...(limit ? { limit } : {}),
|
|
219
|
+
}), 'read logs');
|
|
220
|
+
return answer.logs ?? [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The message of each line of entries(filter). */
|
|
224
|
+
async messages(filter = {}) {
|
|
225
|
+
return (await this.entries(filter)).map((e) => e.message ?? e.msg);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Resolves with the first log line (stored now, or arriving later) for which `predicate(entry)` is true, waiting on
|
|
230
|
+
* the log stream, never on a timer, but giving up with a TestError TIMEOUT at `timeoutMs`. This is how a test waits for
|
|
231
|
+
* what a trigger or another asynchronous run logs.
|
|
232
|
+
*/
|
|
233
|
+
async waitFor(predicate, { timeoutMs = DEFAULT_WAIT_MS, function: fn, level } = {}) {
|
|
234
|
+
const abort = new AbortController();
|
|
235
|
+
const filter = { ...(fn ? { function: fn } : {}), ...(level ? { level } : {}) };
|
|
236
|
+
let arrived;
|
|
237
|
+
const found = new Promise((resolve) => { arrived = resolve; });
|
|
238
|
+
let connected;
|
|
239
|
+
const ready = new Promise((resolve) => { connected = resolve; });
|
|
240
|
+
const following = this.follow(filter, (entry) => { if (predicate(entry)) arrived(entry); }, abort.signal, connected);
|
|
241
|
+
const deadline = new Promise((_, reject) => {
|
|
242
|
+
const timer = setTimeout(() => reject(new TestError(`No log line matched within ${timeoutMs} ms`, TestErrorCode.TIMEOUT)), timeoutMs);
|
|
243
|
+
found.finally(() => clearTimeout(timer));
|
|
244
|
+
abort.signal.addEventListener('abort', () => clearTimeout(timer), { once: true });
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
// Subscribed first, so nothing logged from here on is missed; then what is already stored.
|
|
248
|
+
await Promise.race([ready, following, deadline]);
|
|
249
|
+
const stored = (await this.entries(filter)).find(predicate);
|
|
250
|
+
if (stored !== undefined) return stored;
|
|
251
|
+
return await Promise.race([found, deadline, following.then(() => { throw new TestError('The log stream ended', TestErrorCode.TIMEOUT); })]);
|
|
252
|
+
} finally {
|
|
253
|
+
abort.abort();
|
|
254
|
+
await following.catch(() => {});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Fetch stubs and calls
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/** A fetch a function made, as the server recorded it. */
|
|
264
|
+
export class FetchCall {
|
|
265
|
+
constructor(record) {
|
|
266
|
+
this.seq = record.seq;
|
|
267
|
+
this.atMs = record.at_ms;
|
|
268
|
+
this.method = record.method;
|
|
269
|
+
this.url = record.url;
|
|
270
|
+
this.headers = record.headers;
|
|
271
|
+
this.body = record.body;
|
|
272
|
+
this.outcome = member(CallOutcome, record.outcome, 'a call outcome');
|
|
273
|
+
this.stubId = record.stub_id;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The request body parsed as JSON. */
|
|
277
|
+
json() { return JSON.parse(this.body); }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The outbound requests of the test tenant's functions: stubbed, and recorded. Anything unstubbed is rejected. */
|
|
281
|
+
export class FetchStubs {
|
|
282
|
+
constructor(transport) { this.transport = transport; }
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Stubs one request. `method` omitted: any method. `json` or `text` is the response body (not both);
|
|
286
|
+
* `times` answers before the stub is spent (omitted: no limit). Returns the stub's id.
|
|
287
|
+
*/
|
|
288
|
+
async stub({ method, url, pathMatch = PathMatch.EXACT, status = 200, headers = {}, json, text, times } = {}) {
|
|
289
|
+
if (typeof url !== 'string' || url === '') throw new TestError('A stub needs the url its function fetches', TestErrorCode.INVALID_ARGUMENT);
|
|
290
|
+
if (method !== undefined) member(HttpMethod, method, 'a stub method');
|
|
291
|
+
member(PathMatch, pathMatch, 'a stub path match');
|
|
292
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) throw new TestError('A stub status is a whole number from 100 to 599', TestErrorCode.INVALID_ARGUMENT);
|
|
293
|
+
if (json !== undefined && text !== undefined) throw new TestError('A stub has json or text, not both', TestErrorCode.INVALID_ARGUMENT);
|
|
294
|
+
if (times !== undefined && (!Number.isInteger(times) || times < 0)) throw new TestError('A stub times is a whole number, 0 for no limit', TestErrorCode.INVALID_ARGUMENT);
|
|
295
|
+
const body = json !== undefined ? JSON.stringify(json) : (text ?? '');
|
|
296
|
+
const withType = json !== undefined && !Object.keys(headers).some((h) => h.toLowerCase() === 'content-type')
|
|
297
|
+
? { 'content-type': 'application/json', ...headers } : headers;
|
|
298
|
+
const answer = expectOk(await this.transport('sandbox.stub.set', {
|
|
299
|
+
...(method ? { method } : {}), url, path_match: pathMatch, status, headers: withType, body,
|
|
300
|
+
...(times !== undefined ? { times } : {}),
|
|
301
|
+
}), `stub ${url}`);
|
|
302
|
+
return answer.id;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Every fetch made since the last clear, oldest first. */
|
|
306
|
+
async calls() {
|
|
307
|
+
return expectOk(await this.transport('sandbox.stub.list', {}), 'read fetch calls').calls.map((c) => new FetchCall(c));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The stubs, with how often each answered. */
|
|
311
|
+
async stubs() {
|
|
312
|
+
return expectOk(await this.transport('sandbox.stub.list', {}), 'read stubs').stubs;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Forgets every stub and every recorded call. */
|
|
316
|
+
async clear() {
|
|
317
|
+
const answer = expectOk(await this.transport('sandbox.stub.clear', {}), 'clear stubs');
|
|
318
|
+
return { stubs: answer.cleared_stubs, calls: answer.cleared_calls };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Clock
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/** The test tenant's clock: what Date.now() and new Date() give its functions. setTimeout and cron stay on real time. */
|
|
327
|
+
export class TestTenantClock {
|
|
328
|
+
#setting = { mode: ClockMode.REAL, at: null, offsetMs: null };
|
|
329
|
+
|
|
330
|
+
constructor(transport) { this.transport = transport; }
|
|
331
|
+
|
|
332
|
+
async #set(body) {
|
|
333
|
+
const answer = expectOk(await this.transport('tenant.clock.set', body), 'set the clock');
|
|
334
|
+
this.#setting = { mode: answer.mode, at: answer.at, offsetMs: answer.offset_ms };
|
|
335
|
+
return { mode: answer.mode, at: answer.at, offsetMs: answer.offset_ms, nowMs: answer.now_ms };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Freezes the clock at `at`: a Date or Unix milliseconds. */
|
|
339
|
+
freeze(at) {
|
|
340
|
+
const ms = at instanceof Date ? at.getTime() : at;
|
|
341
|
+
if (!Number.isInteger(ms)) throw new TestError('freeze takes a Date or whole Unix milliseconds', TestErrorCode.INVALID_ARGUMENT);
|
|
342
|
+
return this.#set({ mode: ClockMode.FROZEN, at: ms });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Real time plus `ms` (negative: earlier). */
|
|
346
|
+
offset(ms) {
|
|
347
|
+
if (!Number.isInteger(ms)) throw new TestError('offset takes whole milliseconds', TestErrorCode.INVALID_ARGUMENT);
|
|
348
|
+
return this.#set({ mode: ClockMode.OFFSET, offset_ms: ms });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Moves the clock on by `ms`: a frozen clock stays frozen, anything else gets that much more offset. */
|
|
352
|
+
advance(ms) {
|
|
353
|
+
if (!Number.isInteger(ms)) throw new TestError('advance takes whole milliseconds', TestErrorCode.INVALID_ARGUMENT);
|
|
354
|
+
const { mode, at, offsetMs } = this.#setting;
|
|
355
|
+
if (mode === ClockMode.FROZEN) return this.freeze(at + ms);
|
|
356
|
+
return this.offset((mode === ClockMode.OFFSET ? offsetMs : 0) + ms);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Real time again. */
|
|
360
|
+
release() { return this.#set({ mode: ClockMode.REAL }); }
|
|
361
|
+
|
|
362
|
+
/** The clock's reading now, in Unix milliseconds. */
|
|
363
|
+
async now() {
|
|
364
|
+
const { mode, at, offsetMs } = this.#setting;
|
|
365
|
+
const body = mode === ClockMode.FROZEN ? { mode, at } : mode === ClockMode.OFFSET ? { mode, offset_ms: offsetMs } : { mode };
|
|
366
|
+
return (await this.#set(body)).nowMs;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// The app
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/** An HTTP request to the test tenant's routes, with its Host (node:http: fetch would drop it). */
|
|
375
|
+
export function httpTo({ base, host }) {
|
|
376
|
+
const url = new URL(base);
|
|
377
|
+
return (method, path, { headers = {}, body } = {}) => new Promise((resolve, reject) => {
|
|
378
|
+
member(HttpMethod, method, 'a request method');
|
|
379
|
+
const payload = body === undefined ? undefined : typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body);
|
|
380
|
+
const req = httpRequest({
|
|
381
|
+
host: url.hostname, port: url.port, method, path,
|
|
382
|
+
headers: {
|
|
383
|
+
host: `${host}:${url.port}`,
|
|
384
|
+
...(payload !== undefined ? { 'content-length': Buffer.byteLength(payload), 'content-type': typeof body === 'string' ? 'text/plain' : 'application/json' } : {}),
|
|
385
|
+
...headers,
|
|
386
|
+
},
|
|
387
|
+
}, (res) => {
|
|
388
|
+
const chunks = [];
|
|
389
|
+
res.on('data', (c) => chunks.push(c));
|
|
390
|
+
res.on('end', () => {
|
|
391
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
392
|
+
resolve({ status: res.statusCode, headers: new Headers(res.headers), text, json: () => JSON.parse(text) });
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
req.on('error', reject);
|
|
396
|
+
if (payload !== undefined) req.write(payload);
|
|
397
|
+
req.end();
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** One isolated test tenant running the app. */
|
|
402
|
+
export class TestApp {
|
|
403
|
+
/**
|
|
404
|
+
* @param {object} parts
|
|
405
|
+
* @param {string} parts.slug the tenant's slug
|
|
406
|
+
* @param {string[]} parts.functions the names of the functions the app deploys
|
|
407
|
+
* @param {Function} parts.transport
|
|
408
|
+
* @param {Function} parts.follow log stream (see TestLogs)
|
|
409
|
+
* @param {Function} parts.http (method, path, options) => response
|
|
410
|
+
* @param {() => Promise<void>} parts.dispose removes the tenant
|
|
411
|
+
*/
|
|
412
|
+
constructor({ slug, functions, transport, follow, http, dispose }) {
|
|
413
|
+
this.slug = slug;
|
|
414
|
+
this.functions = functions;
|
|
415
|
+
this.transport = transport;
|
|
416
|
+
this.db = new TestDb(transport);
|
|
417
|
+
this.logs = new TestLogs(transport, follow);
|
|
418
|
+
this.fetch = new FetchStubs(transport);
|
|
419
|
+
this.clock = new TestTenantClock(transport);
|
|
420
|
+
this.http = http;
|
|
421
|
+
this.dispose = dispose;
|
|
422
|
+
this.closed = null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The function `name`; a name the app does not deploy is a TestError listing the ones it does. */
|
|
426
|
+
fn(name) {
|
|
427
|
+
if (!this.functions.includes(name)) {
|
|
428
|
+
throw new TestError(`The app deploys no function ${JSON.stringify(name)}; it deploys ${this.functions.join(', ') || 'none'}`, TestErrorCode.UNKNOWN_FUNCTION);
|
|
429
|
+
}
|
|
430
|
+
return new FunctionRef(name, this.transport);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Runs the schedule of `functionName` now (schedule.run) with its cron event; a Run of how it ended. */
|
|
434
|
+
async cron(functionName) {
|
|
435
|
+
const listed = expectOk(await this.transport('schedule.list', {}), 'list schedules');
|
|
436
|
+
const entries = listed.cron_triggers ?? listed.schedules ?? [];
|
|
437
|
+
const entry = entries.find((e) => (e.function ?? e.function_name) === functionName);
|
|
438
|
+
if (entry === undefined) {
|
|
439
|
+
throw new TestError(`The app has no [[cron]] for ${JSON.stringify(functionName)}; it has ${entries.map((e) => e.function ?? e.function_name).join(', ') || 'none'}`, TestErrorCode.NO_SCHEDULE);
|
|
440
|
+
}
|
|
441
|
+
const result = await this.transport('schedule.run', { id: entry.id });
|
|
442
|
+
if (!result.ok) return runOf(result);
|
|
443
|
+
const run = result.data;
|
|
444
|
+
return new Run({
|
|
445
|
+
outcome: run.outcome === 'ok' ? RunOutcome.OK : RunOutcome.ERROR, status: result.status, value: run.result ?? null,
|
|
446
|
+
error: run.outcome === 'ok' ? null : { code: null, message: run.error ?? 'the run failed', functionError: run.error_id ?? null },
|
|
447
|
+
durationMs: run.duration_ms,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Calls `functionName` as the trigger of a change would (the event the server builds), directly. */
|
|
452
|
+
async trigger(functionName, { kind, collection, id, data }) {
|
|
453
|
+
const event = triggerEvent({ kind, collection, id, data, timestamp: await this.clock.now() });
|
|
454
|
+
return this.fn(functionName).call(event);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Removes the tenant and everything in it. Safe to call again. */
|
|
458
|
+
close() {
|
|
459
|
+
this.closed ??= this.dispose();
|
|
460
|
+
return this.closed;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Makes a test tenant on the server `substrio test` runs, applies the app to it (the
|
|
466
|
+
* way `substrio dev` does: one version of substrio.toml and its files), loads `fixtures`
|
|
467
|
+
* and returns the TestApp. Every call is a new tenant: no data, stub or clock is
|
|
468
|
+
* shared between two. Pass the node:test context as `context` to have the tenant
|
|
469
|
+
* removed after the test.
|
|
470
|
+
*/
|
|
471
|
+
export async function startApp({ fixtures = [], cwd = null, context = null, env = process.env } = {}) {
|
|
472
|
+
const test = readTestEnv(env);
|
|
473
|
+
const appDir = cwd ?? test.appDir;
|
|
474
|
+
for (const f of fixtures) if (!(f instanceof Fixture)) throw new TestError('fixtures are fixture(collection, documents) values', TestErrorCode.INVALID_FIXTURE);
|
|
475
|
+
const server = LocalServer.attach({ url: test.url, masterKey: readFileSync(test.keyFile, 'utf8').trim() });
|
|
476
|
+
const slug = `t${randomBytes(6).toString('hex')}`;
|
|
477
|
+
const tenant = await server.createTenant(slug, { test: true });
|
|
478
|
+
const dispose = () => server.deleteTenant(slug);
|
|
479
|
+
try {
|
|
480
|
+
const { config, manifest } = readApp(appDir);
|
|
481
|
+
// No sidecars for tests: brotli at its best quality per tenant would be slow.
|
|
482
|
+
const plain = manifest.site ? { ...manifest, site: { ...manifest.site, precompress: null } } : manifest;
|
|
483
|
+
const loaded = await loadApp(config, plain, appDir, undefined);
|
|
484
|
+
// The app's declared collections and schemas, as `substrio dev` has them, before the version whose rules are about them.
|
|
485
|
+
await applyCollections(tenant.invoke, loaded.collections);
|
|
486
|
+
const published = await publishVersion(tenant.api, tenant.ops, loaded, tenant.invoke);
|
|
487
|
+
await makeLive(tenant.invoke, published.number, { backup: false });
|
|
488
|
+
const transport = transportOf(tenant.api, tenant.ops);
|
|
489
|
+
const app = new TestApp({
|
|
490
|
+
slug,
|
|
491
|
+
functions: loaded.app.functions.map((f) => f.name),
|
|
492
|
+
transport,
|
|
493
|
+
follow: (filter, onEntry, signal, onConnected) => followLogs(tenant.api, filter, onEntry, signal, onConnected),
|
|
494
|
+
http: httpTo({ base: server.url, host: `${slug}.localhost` }),
|
|
495
|
+
dispose,
|
|
496
|
+
});
|
|
497
|
+
await app.db.load(fixtures);
|
|
498
|
+
context?.after(() => app.close());
|
|
499
|
+
return app;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
await dispose().catch(() => {});
|
|
502
|
+
if (err instanceof TestError) throw err;
|
|
503
|
+
const failure = new TestError(`The app could not be applied to the test tenant: ${err.message}`, TestErrorCode.APP_FAILED);
|
|
504
|
+
failure.cause = err;
|
|
505
|
+
throw failure;
|
|
506
|
+
}
|
|
507
|
+
}
|