@dokebi/cli 0.4.1 → 0.4.26
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/AI.md +262 -57
- package/README.md +35 -10
- package/catalog.json +2519 -257
- package/describe.schema.json +748 -0
- package/dist/api-app.d.ts +2 -0
- package/dist/api-app.js +35 -0
- package/dist/app-urls.js +3 -2
- package/dist/bin.js +527 -35
- package/dist/capability-guide.d.ts +30 -0
- package/dist/capability-guide.js +122 -0
- package/dist/catalog.d.ts +16 -2
- package/dist/catalog.js +19 -7
- package/dist/check/asset-rules.d.ts +6 -0
- package/dist/check/asset-rules.js +163 -0
- package/dist/check/browser-sources.d.ts +20 -0
- package/dist/check/browser-sources.js +89 -0
- package/dist/check/console-rules.d.ts +6 -0
- package/dist/check/console-rules.js +40 -0
- package/dist/check/error-rules.d.ts +21 -0
- package/dist/check/error-rules.js +212 -0
- package/dist/check/form-rules.d.ts +12 -0
- package/dist/check/form-rules.js +69 -6
- package/dist/check/formatter-rules.d.ts +2 -0
- package/dist/check/formatter-rules.js +71 -0
- package/dist/check/i18n-rules.d.ts +27 -0
- package/dist/check/i18n-rules.js +353 -0
- package/dist/check/index.d.ts +15 -1
- package/dist/check/index.js +89 -11
- package/dist/check/mail-rules.d.ts +4 -0
- package/dist/check/mail-rules.js +72 -0
- package/dist/check/module-rules.d.ts +13 -0
- package/dist/check/module-rules.js +104 -0
- package/dist/check/packages.d.ts +8 -0
- package/dist/check/packages.js +16 -0
- package/dist/check/pool-rules.d.ts +8 -0
- package/dist/check/pool-rules.js +164 -0
- package/dist/check/problems.d.ts +6 -1
- package/dist/check/problems.js +100 -2
- package/dist/check/project-rules.d.ts +23 -3
- package/dist/check/project-rules.js +265 -23
- package/dist/check/seal-rules.d.ts +6 -0
- package/dist/check/seal-rules.js +68 -0
- package/dist/check/security-rules.d.ts +10 -0
- package/dist/check/security-rules.js +450 -0
- package/dist/check/shared-state-rules.d.ts +2 -0
- package/dist/check/shared-state-rules.js +114 -0
- package/dist/check/source-rules.d.ts +26 -3
- package/dist/check/source-rules.js +249 -28
- package/dist/check/structure-rules.d.ts +23 -0
- package/dist/check/structure-rules.js +502 -0
- package/dist/check/timer-rules.d.ts +7 -0
- package/dist/check/timer-rules.js +543 -0
- package/dist/check/typescript.d.ts +35 -5
- package/dist/check/typescript.js +163 -17
- package/dist/check/vite-rules.d.ts +2 -0
- package/dist/check/vite-rules.js +136 -0
- package/dist/check/wait-rules.d.ts +16 -0
- package/dist/check/wait-rules.js +337 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +307 -31
- package/dist/config-diff.d.ts +42 -0
- package/dist/config-diff.js +289 -0
- package/dist/config-edit.d.ts +35 -2
- package/dist/config-edit.js +145 -13
- package/dist/config-init.d.ts +15 -0
- package/dist/config-init.js +61 -0
- package/dist/config-paths.d.ts +22 -0
- package/dist/config-paths.js +82 -0
- package/dist/config-show.d.ts +82 -0
- package/dist/config-show.js +563 -0
- package/dist/config-values.d.ts +8 -0
- package/dist/config-values.js +68 -0
- package/dist/configuration-scaffold.d.ts +42 -0
- package/dist/configuration-scaffold.js +258 -4
- package/dist/configuration.d.ts +63 -3
- package/dist/configuration.js +178 -4
- package/dist/deployment.d.ts +2 -0
- package/dist/deployment.js +111 -0
- package/dist/describe-db.d.ts +22 -0
- package/dist/describe-db.js +176 -0
- package/dist/describe.d.ts +92 -0
- package/dist/describe.js +417 -0
- package/dist/dev-compose.d.ts +20 -0
- package/dist/dev-compose.js +154 -0
- package/dist/dev-guard.js +44 -0
- package/dist/dev-host.d.ts +13 -0
- package/dist/dev-host.js +55 -0
- package/dist/dev-infra.d.ts +72 -4
- package/dist/dev-infra.js +237 -17
- package/dist/dev-watch.d.ts +34 -0
- package/dist/dev-watch.js +192 -0
- package/dist/diagnostics.d.ts +10 -7
- package/dist/diagnostics.js +22 -4
- package/dist/drift.d.ts +50 -0
- package/dist/drift.js +218 -0
- package/dist/env-file.d.ts +25 -1
- package/dist/env-file.js +64 -6
- package/dist/errors.d.ts +7 -1
- package/dist/example-config.d.ts +2 -0
- package/dist/example-config.js +3 -0
- package/dist/first-admin.d.ts +43 -0
- package/dist/first-admin.js +175 -0
- package/dist/formatter-ignore.d.ts +37 -0
- package/dist/formatter-ignore.js +344 -0
- package/dist/generator.d.ts +45 -3
- package/dist/generator.js +205 -15
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +42 -21
- package/dist/install-module.d.ts +29 -3
- package/dist/install-module.js +142 -13
- package/dist/install-plan.js +5 -3
- package/dist/layout.d.ts +12 -1
- package/dist/layout.js +19 -6
- package/dist/log-writer-control.d.ts +2 -0
- package/dist/log-writer-control.js +106 -27
- package/dist/log-writer.js +43 -10
- package/dist/logging-config.d.ts +8 -3
- package/dist/logging-config.js +19 -5
- package/dist/manifest.d.ts +29 -1
- package/dist/manifest.js +84 -6
- package/dist/merge-install-defaults.d.ts +0 -1
- package/dist/merge-install-defaults.js +13 -47
- package/dist/npm-client.d.ts +59 -4
- package/dist/npm-client.js +220 -16
- package/dist/npm-paths.js +18 -4
- package/dist/openapi.d.ts +27 -0
- package/dist/openapi.js +649 -0
- package/dist/option-fixes.d.ts +39 -0
- package/dist/option-fixes.js +130 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +5 -5
- package/dist/plugin-lifecycle.d.ts +34 -2
- package/dist/plugin-lifecycle.js +206 -20
- package/dist/plugin-options.d.ts +19 -4
- package/dist/plugin-options.js +138 -6
- package/dist/plugins-command.js +44 -7
- package/dist/process-group.d.ts +39 -0
- package/dist/process-group.js +132 -0
- package/dist/project-guide.d.ts +19 -1
- package/dist/project-guide.js +119 -20
- package/dist/project-inspector.d.ts +15 -0
- package/dist/project-inspector.js +481 -33
- package/dist/project-state.d.ts +5 -0
- package/dist/project-state.js +8 -0
- package/dist/project.d.ts +203 -19
- package/dist/project.js +3016 -336
- package/dist/recipe-facts.d.ts +127 -0
- package/dist/recipe-facts.js +563 -0
- package/dist/recipe-login.d.ts +63 -0
- package/dist/recipe-login.js +464 -0
- package/dist/recipe-mail.d.ts +21 -0
- package/dist/recipe-mail.js +214 -0
- package/dist/recipe-members.d.ts +35 -0
- package/dist/recipe-members.js +178 -0
- package/dist/recipe-record.d.ts +82 -7
- package/dist/recipe-record.js +226 -22
- package/dist/recipe-reset.d.ts +2 -0
- package/dist/recipe-reset.js +117 -0
- package/dist/recipe-site.d.ts +48 -0
- package/dist/recipe-site.js +396 -0
- package/dist/recipe-step.d.ts +42 -0
- package/dist/recipe-step.js +89 -0
- package/dist/recipe.d.ts +56 -20
- package/dist/recipe.js +703 -131
- package/dist/registry.d.ts +98 -2
- package/dist/registry.js +171 -5
- package/dist/reloadable.d.ts +35 -0
- package/dist/reloadable.js +257 -0
- package/dist/server.d.ts +50 -4
- package/dist/server.js +790 -103
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +168 -0
- package/dist/site-auth.d.ts +39 -0
- package/dist/site-auth.js +195 -0
- package/dist/template-fixes.d.ts +26 -0
- package/dist/template-fixes.js +285 -0
- package/dist/test-reporter.d.ts +49 -0
- package/dist/test-reporter.js +190 -0
- package/dist/transaction.d.ts +67 -0
- package/dist/transaction.js +428 -46
- package/dist/verify-tests.d.ts +98 -0
- package/dist/verify-tests.js +449 -0
- package/dist/verify.d.ts +113 -4
- package/dist/verify.js +326 -28
- package/dist/view-choice.d.ts +58 -0
- package/dist/view-choice.js +131 -0
- package/dist/vite-watch.js +4 -2
- package/package.json +8 -6
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// The `test` step of `dokebi verify` (D-0077 H): the project's own tests, with a result per test.
|
|
2
|
+
//
|
|
3
|
+
// - Only when package.json has a `test` script; without one the step is skipped (`no test
|
|
4
|
+
// script`), never a failure.
|
|
5
|
+
// - A script that is a plain `node … --test …` command (optionally after `KEY=value` words) is run
|
|
6
|
+
// directly with the CLI's node and the reporter in test-reporter.ts, which writes one JSON line
|
|
7
|
+
// per finished test to a temporary file: name, `[ID]` tag, ok, ms, file, skipped, todo and the
|
|
8
|
+
// first line of the failure. Anything else (another runner, shell syntax, a pretest/posttest
|
|
9
|
+
// script) runs as `npm run test` and only its exit code and duration are recorded.
|
|
10
|
+
// - The tests get the app's environment: the project's .env under the CLI's environment
|
|
11
|
+
// (D-0073), NODE_ENV=test unless one of them sets it. No stdout or stderr of the tests is ever
|
|
12
|
+
// copied into the result; an error line is masked again with every secret value verify can know
|
|
13
|
+
// (.env values, secret-named environment variables, dokebi.yaml secrets as `config show` hides
|
|
14
|
+
// them) — and dropped when those cannot be read (fail closed).
|
|
15
|
+
// - The tests run in their own process group, which is killed at the time limit and after the
|
|
16
|
+
// run (a server a test left behind must not hold a port when verify starts the app).
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { delimiter, join, relative, sep } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { redaction } from '@dokebi/core/libraries';
|
|
23
|
+
import { configShow, SECRET_MARKER, URL_SECRET_MARKER } from './config-show.js';
|
|
24
|
+
import { configTarget, parseProjectConfig, readProjectSettings } from './configuration.js';
|
|
25
|
+
import { debug, describeError, isMissing } from './diagnostics.js';
|
|
26
|
+
import { appEnvironment, readEnvFile } from './env-file.js';
|
|
27
|
+
import { CliError } from './errors.js';
|
|
28
|
+
import { redactNpmOutput } from './npm-client.js';
|
|
29
|
+
import { forwardTermination, killGroup, PROCESS_GROUPS } from './process-group.js';
|
|
30
|
+
/** Default time limit of the test step (`--test-timeout-ms`), like typecheck and build. */
|
|
31
|
+
export const TEST_TIMEOUT_MS = 300_000;
|
|
32
|
+
/** At most this many results are listed; failures always are (`omitted` counts the rest). */
|
|
33
|
+
export const MAX_TEST_RESULTS = 1000;
|
|
34
|
+
/** A leading `[ABC-12] ` tag of a test name: the id (D-0077 A; the framework gives it no meaning). */
|
|
35
|
+
export const TEST_ID = /^\[([A-Za-z][A-Za-z0-9._-]{0,63})\]\s/;
|
|
36
|
+
/**
|
|
37
|
+
* The words of a script, or undefined when it uses anything a shell would interpret (operators,
|
|
38
|
+
* substitutions, escapes, globs outside quotes are kept as words: node expands them itself).
|
|
39
|
+
*/
|
|
40
|
+
export function scriptWords(script) {
|
|
41
|
+
const words = [];
|
|
42
|
+
let word;
|
|
43
|
+
let quote;
|
|
44
|
+
for (const character of script) {
|
|
45
|
+
if (quote) {
|
|
46
|
+
if (character === quote)
|
|
47
|
+
quote = undefined;
|
|
48
|
+
else if (quote === '"' && /[$`\\]/.test(character))
|
|
49
|
+
return undefined;
|
|
50
|
+
else
|
|
51
|
+
word += character;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (character === '"' || character === "'") {
|
|
55
|
+
quote = character;
|
|
56
|
+
word ??= '';
|
|
57
|
+
}
|
|
58
|
+
else if (/\s/.test(character)) {
|
|
59
|
+
if (word !== undefined)
|
|
60
|
+
words.push(word);
|
|
61
|
+
word = undefined;
|
|
62
|
+
}
|
|
63
|
+
else if (/[|&;<>()$`\\{}~#!]/.test(character))
|
|
64
|
+
return undefined;
|
|
65
|
+
else
|
|
66
|
+
word = (word ?? '') + character;
|
|
67
|
+
}
|
|
68
|
+
if (quote)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (word !== undefined)
|
|
71
|
+
words.push(word);
|
|
72
|
+
return words;
|
|
73
|
+
}
|
|
74
|
+
const REPORTER_FLAGS = ['--test-reporter', '--test-reporter-destination'];
|
|
75
|
+
/** Node options whose value is the next word (`--import tsx`). */
|
|
76
|
+
const VALUE_FLAGS = new Set([
|
|
77
|
+
'--import',
|
|
78
|
+
'--require',
|
|
79
|
+
'-r',
|
|
80
|
+
'--loader',
|
|
81
|
+
'--experimental-loader',
|
|
82
|
+
'--env-file',
|
|
83
|
+
'--conditions',
|
|
84
|
+
'-C',
|
|
85
|
+
...REPORTER_FLAGS,
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Whether `--test` is a node option of these words: before the first file or pattern (after it,
|
|
89
|
+
* node passes `--test` to the script instead).
|
|
90
|
+
*/
|
|
91
|
+
function testFlagFirst(words) {
|
|
92
|
+
for (let index = 0; index < words.length; index++) {
|
|
93
|
+
const word = words[index];
|
|
94
|
+
if (word === '--test')
|
|
95
|
+
return true;
|
|
96
|
+
if (!word.startsWith('-'))
|
|
97
|
+
return false;
|
|
98
|
+
if (VALUE_FLAGS.has(word))
|
|
99
|
+
index++;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/** Whether verify can run `script` with its reporter, and how. */
|
|
104
|
+
export function planTests(script, scripts) {
|
|
105
|
+
if (typeof scripts.pretest === 'string' || typeof scripts.posttest === 'string')
|
|
106
|
+
return { runner: 'other', reason: 'package.json has a pretest or posttest script' };
|
|
107
|
+
const words = scriptWords(script.trim());
|
|
108
|
+
if (!words)
|
|
109
|
+
return { runner: 'other', reason: 'the test script uses shell syntax' };
|
|
110
|
+
const env = {};
|
|
111
|
+
while (words[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
|
|
112
|
+
const assignment = words.shift();
|
|
113
|
+
const equals = assignment.indexOf('=');
|
|
114
|
+
env[assignment.slice(0, equals)] = assignment.slice(equals + 1);
|
|
115
|
+
}
|
|
116
|
+
if (words.shift() !== 'node')
|
|
117
|
+
return { runner: 'other', reason: 'the test script is not a node --test command' };
|
|
118
|
+
if (!testFlagFirst(words))
|
|
119
|
+
return { runner: 'other', reason: 'the test script is not a node --test command' };
|
|
120
|
+
const args = [];
|
|
121
|
+
for (let index = 0; index < words.length; index++) {
|
|
122
|
+
const word = words[index];
|
|
123
|
+
const flag = REPORTER_FLAGS.find((name) => word === name || word.startsWith(`${name}=`));
|
|
124
|
+
if (!flag)
|
|
125
|
+
args.push(word);
|
|
126
|
+
else if (word === flag)
|
|
127
|
+
index++; // `--test-reporter spec`: its value is the next word
|
|
128
|
+
}
|
|
129
|
+
return { runner: 'node', args, env };
|
|
130
|
+
}
|
|
131
|
+
/** The id of a test: the tag of its own name, else of the nearest parent with one. */
|
|
132
|
+
export function testId(name, parents = []) {
|
|
133
|
+
for (const candidate of [name, ...[...parents].reverse()]) {
|
|
134
|
+
const match = TEST_ID.exec(candidate);
|
|
135
|
+
if (match?.[1])
|
|
136
|
+
return match[1];
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
/** All strings in a value (a secret list hides every item). */
|
|
141
|
+
function strings(value, into) {
|
|
142
|
+
if (typeof value === 'string')
|
|
143
|
+
into.push(value, ...maskedCredentials(value));
|
|
144
|
+
else if (Array.isArray(value))
|
|
145
|
+
for (const item of value)
|
|
146
|
+
strings(item, into);
|
|
147
|
+
else if (value && typeof value === 'object')
|
|
148
|
+
for (const item of Object.values(value))
|
|
149
|
+
strings(item, into);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The credentials core's `maskCredentials` hides in a text (`//user:pa/ss@` of a URL, also with a
|
|
153
|
+
* raw `/` in the password; `Password=...` of a connection string): the text itself and the hidden
|
|
154
|
+
* part, read off its masked form, so verify hides exactly what the logs hide (no rule of its own).
|
|
155
|
+
*/
|
|
156
|
+
export function maskedCredentials(text) {
|
|
157
|
+
const value = text.trim();
|
|
158
|
+
const masked = redaction.maskCredentials(value);
|
|
159
|
+
if (masked === value)
|
|
160
|
+
return [];
|
|
161
|
+
let start = 0;
|
|
162
|
+
while (start < value.length && value[start] === masked[start])
|
|
163
|
+
start++;
|
|
164
|
+
let end = 0;
|
|
165
|
+
while (end < value.length - start &&
|
|
166
|
+
end < masked.length - start &&
|
|
167
|
+
value[value.length - 1 - end] === masked[masked.length - 1 - end])
|
|
168
|
+
end++;
|
|
169
|
+
const hidden = value.slice(start, value.length - end);
|
|
170
|
+
// `user:password` of a URL: the password alone too (it may show up without its user).
|
|
171
|
+
const colon = hidden.indexOf(':');
|
|
172
|
+
return [value, hidden, ...(colon >= 0 ? [hidden.slice(colon + 1)] : [])].filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
/** The raw values `config show` hides: walks the raw options next to the shown ones. */
|
|
175
|
+
function hiddenValues(raw, shown, into) {
|
|
176
|
+
if (shown === SECRET_MARKER) {
|
|
177
|
+
strings(raw, into);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// A URL or connection string with credentials, whether `config show` marked it or not.
|
|
181
|
+
if (typeof raw === 'string') {
|
|
182
|
+
into.push(...maskedCredentials(raw));
|
|
183
|
+
if (typeof shown === 'string' && shown.includes(URL_SECRET_MARKER))
|
|
184
|
+
into.push(raw.trim());
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (Array.isArray(raw) && Array.isArray(shown))
|
|
188
|
+
raw.forEach((item, index) => {
|
|
189
|
+
hiddenValues(item, shown[index], into);
|
|
190
|
+
});
|
|
191
|
+
else if (raw && typeof raw === 'object' && shown && typeof shown === 'object')
|
|
192
|
+
for (const [key, item] of Object.entries(raw))
|
|
193
|
+
hiddenValues(item, shown[key], into);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Every secret value verify can know for this project: the values of .env, of environment
|
|
197
|
+
* variables named like a secret (core's `redaction.isSecretEnvironmentName`: the field rule of the
|
|
198
|
+
* logs plus `*_KEY`, `*_PASS`; never PWD or OLDPWD, which would cut the project folder out of
|
|
199
|
+
* every error line), and of the options of the settings file (DOKEBI_CONFIG, else dokebi.yaml) `config show` hides. Throws when they cannot be
|
|
200
|
+
* read (the caller then drops the error lines).
|
|
201
|
+
*/
|
|
202
|
+
export async function knownSecrets(root) {
|
|
203
|
+
const values = [...Object.values(readEnvFile(root))];
|
|
204
|
+
for (const [name, value] of Object.entries(process.env))
|
|
205
|
+
if (value && redaction.isSecretEnvironmentName(name, value))
|
|
206
|
+
values.push(value);
|
|
207
|
+
// M50: the settings file the app and its tests read (DOKEBI_CONFIG, else dokebi.yaml). One
|
|
208
|
+
// read: `config show` hides in the same text the raw values are taken from.
|
|
209
|
+
let settings;
|
|
210
|
+
try {
|
|
211
|
+
settings = await readProjectSettings(root);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
// No dokebi.yaml holds no secret; a named file that cannot be read fails closed (throws).
|
|
215
|
+
if (!isMissing(error) || (await configTarget(root)).fromEnvironment)
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
if (settings)
|
|
219
|
+
hiddenValues(parseProjectConfig(settings.source).options, (await configShow(root, undefined, { source: settings.source, file: settings.target.file }))
|
|
220
|
+
.value, values);
|
|
221
|
+
// Short values (`1`, `true`, a port) are not secrets and would mask ordinary words.
|
|
222
|
+
return [...new Set(values.filter((value) => value.length >= 6))].sort((a, b) => b.length - a.length);
|
|
223
|
+
}
|
|
224
|
+
/** A secret-named `key=value` / `key: value` in a line (password=…, apiKey: …, token=…). */
|
|
225
|
+
const NAMED_SECRET = /\b([A-Za-z0-9_.-]*(?:secret|password|passwd|passphrase|token|api[_-]?key|credential)s?)(\s*[=:]\s*)(["']?)[^\s"',;]+/gi;
|
|
226
|
+
/** An error line with the known secret values and secret-looking parts replaced by `***`. */
|
|
227
|
+
export function maskLine(line, secrets) {
|
|
228
|
+
let text = line;
|
|
229
|
+
for (const secret of secrets)
|
|
230
|
+
text = text.split(secret).join('***');
|
|
231
|
+
return redactNpmOutput(text).replace(NAMED_SECRET, '$1$2$3***');
|
|
232
|
+
}
|
|
233
|
+
/** Runs a command in its own process group without keeping any output; bounded by timeoutMs. */
|
|
234
|
+
function runBounded(command, args, cwd, env, timeoutMs) {
|
|
235
|
+
const began = performance.now();
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const child = spawn(command, args, {
|
|
238
|
+
cwd,
|
|
239
|
+
env,
|
|
240
|
+
// The tests' own output is never read: it may print anything (secrets included).
|
|
241
|
+
stdio: 'ignore',
|
|
242
|
+
detached: PROCESS_GROUPS,
|
|
243
|
+
});
|
|
244
|
+
const forwarding = forwardTermination(child.pid);
|
|
245
|
+
let timedOut = false;
|
|
246
|
+
const timer = setTimeout(() => {
|
|
247
|
+
timedOut = true;
|
|
248
|
+
debug('verify: the tests reached the time limit; killing them', { timeoutMs });
|
|
249
|
+
killGroup(child.pid, 'SIGKILL');
|
|
250
|
+
}, timeoutMs);
|
|
251
|
+
child.once('error', (cause) => {
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
forwarding();
|
|
254
|
+
reject(new CliError('TEST_FAILED', `Could not run the tests (${command})`, { cause }));
|
|
255
|
+
});
|
|
256
|
+
child.once('close', (code, signal) => {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
forwarding();
|
|
259
|
+
// Whatever the tests left running in the group (a server, a watcher) goes too, so it
|
|
260
|
+
// cannot hold the port of the app verify starts next.
|
|
261
|
+
if (!timedOut && killGroup(child.pid, 'SIGKILL'))
|
|
262
|
+
debug('verify: killed processes the tests left behind', { pid: child.pid });
|
|
263
|
+
resolve({ code, signal, timedOut, ms: Math.round(performance.now() - began) });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/** The reporter lines that are complete JSON objects (a killed run may end mid-line). */
|
|
268
|
+
function reportedTests(text) {
|
|
269
|
+
const tests = [];
|
|
270
|
+
for (const line of text.split('\n')) {
|
|
271
|
+
if (!line.trim())
|
|
272
|
+
continue;
|
|
273
|
+
try {
|
|
274
|
+
tests.push(JSON.parse(line));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
debug('verify: skipped an incomplete reporter line', { error: describeError(error) });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return tests;
|
|
281
|
+
}
|
|
282
|
+
function testFile(root, file) {
|
|
283
|
+
if (!file)
|
|
284
|
+
return undefined;
|
|
285
|
+
const path = relative(root, file);
|
|
286
|
+
return path && !path.startsWith('..') ? path.split(sep).join('/') : file;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The test step of verify for the project at `root` whose package.json scripts are `scripts`.
|
|
290
|
+
* Throws a CliError (ENV_FILE_INVALID, TEST_FAILED) only when the tests cannot be run at all.
|
|
291
|
+
*/
|
|
292
|
+
export async function runTests(root, scripts, options = {}) {
|
|
293
|
+
const script = scripts.test;
|
|
294
|
+
if (typeof script !== 'string')
|
|
295
|
+
return { ok: true, skipped: 'no test script' };
|
|
296
|
+
const timeoutMs = options.timeoutMs ?? TEST_TIMEOUT_MS;
|
|
297
|
+
const plan = planTests(script, scripts);
|
|
298
|
+
debug('verify: running the tests', { runner: plan.runner, timeoutMs });
|
|
299
|
+
// The app's environment (D-0073), as a test run: NODE_ENV=test unless .env or the
|
|
300
|
+
// environment sets it. NODE_TEST_CONTEXT would make node --test act as a child of another
|
|
301
|
+
// run (verify itself under node --test) and report to its stdout instead of the reporter.
|
|
302
|
+
const env = appEnvironment(root, 'test', plan.runner === 'node' ? plan.env : {});
|
|
303
|
+
delete env.NODE_TEST_CONTEXT;
|
|
304
|
+
env.PATH = [join(root, 'node_modules', '.bin'), env.PATH].filter(Boolean).join(delimiter);
|
|
305
|
+
const timedOut = (ms) => ({
|
|
306
|
+
code: 'TEST_TIMEOUT',
|
|
307
|
+
message: `The tests did not finish within ${ms} ms (npx dokebi verify --test-timeout-ms <ms> allows more)`,
|
|
308
|
+
});
|
|
309
|
+
if (plan.runner === 'other') {
|
|
310
|
+
const npmPath = process.env.npm_execpath;
|
|
311
|
+
const viaNode = npmPath?.endsWith('npm-cli.js');
|
|
312
|
+
const run = await runBounded(viaNode ? process.execPath : 'npm', [...(viaNode ? [npmPath] : []), 'run', 'test'], root, env, timeoutMs);
|
|
313
|
+
const ok = run.code === 0 && !run.timedOut;
|
|
314
|
+
const tests = {
|
|
315
|
+
runner: 'other',
|
|
316
|
+
ms: run.ms,
|
|
317
|
+
exitCode: run.code,
|
|
318
|
+
reason: plan.reason,
|
|
319
|
+
};
|
|
320
|
+
return {
|
|
321
|
+
ok,
|
|
322
|
+
...(ok
|
|
323
|
+
? {}
|
|
324
|
+
: run.timedOut
|
|
325
|
+
? timedOut(timeoutMs)
|
|
326
|
+
: {
|
|
327
|
+
code: 'TEST_FAILED',
|
|
328
|
+
message: `npm run test exited with ${run.code ?? run.signal}; run it to see the output (verify does not copy test output)`,
|
|
329
|
+
}),
|
|
330
|
+
details: { runner: 'other', reason: plan.reason, exitCode: run.code },
|
|
331
|
+
tests,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const work = await mkdtemp(join(tmpdir(), 'dokebi-verify-tests-'));
|
|
335
|
+
try {
|
|
336
|
+
const report = join(work, 'report.jsonl');
|
|
337
|
+
const reporter = fileURLToPath(new URL('./test-reporter.js', import.meta.url));
|
|
338
|
+
const run = await runBounded(process.execPath,
|
|
339
|
+
// Node reads options only before the first file or pattern: the reporter goes first.
|
|
340
|
+
[`--test-reporter=${reporter}`, `--test-reporter-destination=${report}`, ...plan.args], root, env, timeoutMs);
|
|
341
|
+
let text = '';
|
|
342
|
+
try {
|
|
343
|
+
text = await readFile(report, 'utf8');
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (!isMissing(error))
|
|
347
|
+
throw error;
|
|
348
|
+
debug('verify: the test run wrote no report', { exitCode: run.code });
|
|
349
|
+
}
|
|
350
|
+
let secrets;
|
|
351
|
+
try {
|
|
352
|
+
secrets = await knownSecrets(root);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
// Fail closed: without the secret list no error line leaves this function.
|
|
356
|
+
debug('verify: the secret values could not be read; error lines are left out', {
|
|
357
|
+
error: describeError(error),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const all = reportedTests(text).map((test) => {
|
|
361
|
+
const id = testId(test.name, test.parents ?? []);
|
|
362
|
+
const file = testFile(root, test.file);
|
|
363
|
+
return {
|
|
364
|
+
name: [...(test.parents ?? []), test.name].join(' > '),
|
|
365
|
+
...(id ? { id } : {}),
|
|
366
|
+
ok: test.ok,
|
|
367
|
+
ms: test.ms,
|
|
368
|
+
...(file ? { file } : {}),
|
|
369
|
+
...(test.skipped ? { skipped: true } : {}),
|
|
370
|
+
...(test.todo ? { todo: true } : {}),
|
|
371
|
+
...(test.error
|
|
372
|
+
? {
|
|
373
|
+
error: secrets
|
|
374
|
+
? maskLine(test.error, secrets)
|
|
375
|
+
: '(hidden: the secret values could not be read to mask it)',
|
|
376
|
+
}
|
|
377
|
+
: {}),
|
|
378
|
+
};
|
|
379
|
+
});
|
|
380
|
+
const failed = all.filter((test) => !test.ok);
|
|
381
|
+
const skipped = all.filter((test) => test.ok && test.skipped).length;
|
|
382
|
+
const todo = all.filter((test) => test.ok && test.todo && !test.skipped).length;
|
|
383
|
+
// Failures always; the others in run order while there is room.
|
|
384
|
+
let room = Math.max(0, MAX_TEST_RESULTS - failed.length);
|
|
385
|
+
const results = all.filter((test) => !test.ok || room-- > 0);
|
|
386
|
+
const tests = {
|
|
387
|
+
runner: 'node',
|
|
388
|
+
total: all.length,
|
|
389
|
+
passed: all.length - failed.length - skipped - todo,
|
|
390
|
+
failed: failed.length,
|
|
391
|
+
skipped,
|
|
392
|
+
todo,
|
|
393
|
+
ms: run.ms,
|
|
394
|
+
exitCode: run.code,
|
|
395
|
+
results,
|
|
396
|
+
...(results.length < all.length ? { omitted: all.length - results.length } : {}),
|
|
397
|
+
};
|
|
398
|
+
const ok = !run.timedOut && run.code === 0 && failed.length === 0;
|
|
399
|
+
const counts = {
|
|
400
|
+
total: tests.total,
|
|
401
|
+
passed: tests.passed,
|
|
402
|
+
failed: tests.failed,
|
|
403
|
+
skipped,
|
|
404
|
+
todo,
|
|
405
|
+
};
|
|
406
|
+
debug('verify: tests finished', { ...counts, exitCode: run.code, ms: run.ms });
|
|
407
|
+
return {
|
|
408
|
+
ok,
|
|
409
|
+
...(ok
|
|
410
|
+
? {}
|
|
411
|
+
: run.timedOut
|
|
412
|
+
? timedOut(timeoutMs)
|
|
413
|
+
: {
|
|
414
|
+
code: 'TEST_FAILED',
|
|
415
|
+
message: failed.length
|
|
416
|
+
? `${failed.length} of ${all.length} test(s) failed: ${failed
|
|
417
|
+
.slice(0, 5)
|
|
418
|
+
.map((test) => test.name)
|
|
419
|
+
.join('; ')}${failed.length > 5 ? '; …' : ''}`
|
|
420
|
+
: `node --test exited with ${run.code ?? run.signal} and no failed test (see npm run test)`,
|
|
421
|
+
}),
|
|
422
|
+
details: {
|
|
423
|
+
runner: 'node',
|
|
424
|
+
...counts,
|
|
425
|
+
exitCode: run.code,
|
|
426
|
+
...(failed.length ? { failures: failed.map((test) => test.name) } : {}),
|
|
427
|
+
},
|
|
428
|
+
tests,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
await rm(work, { recursive: true, force: true }).catch((error) => debug('verify: could not remove the test report folder', {
|
|
433
|
+
work,
|
|
434
|
+
error: describeError(error),
|
|
435
|
+
}));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The human lines verify prints under its test check: one per failing test, then a summary
|
|
440
|
+
* (each line starts with a newline and two spaces).
|
|
441
|
+
*/
|
|
442
|
+
export function testLines(report) {
|
|
443
|
+
if (report.runner === 'other')
|
|
444
|
+
return `\n tests: npm run test exited with ${report.exitCode ?? 'no code'} in ${report.ms} ms (no per-test results: ${report.reason})`;
|
|
445
|
+
const failures = report.results
|
|
446
|
+
.filter((test) => !test.ok)
|
|
447
|
+
.map((test) => `\n FAIL ${test.name}${test.file ? ` (${test.file})` : ''}${test.error ? `: ${test.error}` : ''}`);
|
|
448
|
+
return `${failures.join('')}\n tests: ${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped, ${report.todo} todo of ${report.total} in ${report.ms} ms (node:test)`;
|
|
449
|
+
}
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
|
+
import { type AllowedMarker } from './check/index.js';
|
|
2
|
+
import { type EnvFileWarning } from './env-file.js';
|
|
3
|
+
import * as server from './server.js';
|
|
4
|
+
import { type TestReport } from './verify-tests.js';
|
|
5
|
+
/**
|
|
6
|
+
* D-0105 §4: with @dokebi/db installed, what the agent's final message must contain besides
|
|
7
|
+
* tellUser. The guidance asks for it, and an AI consumer that read it many times still left it
|
|
8
|
+
* out (AI-M23, AI-M24): verify ok repeats it at the moment the agent is about to report.
|
|
9
|
+
*/
|
|
10
|
+
export declare function finalReportLines(ok: boolean, installed: ReadonlySet<string>, allowed?: readonly AllowedMarker[]): string[];
|
|
11
|
+
/**
|
|
12
|
+
* R1 measurement: a builder printed only `{ ok, checks, tests, tellUser }` of the verify JSON and
|
|
13
|
+
* never saw `finalReport`, so its final message lacked the section 6 table. The tellUser lines,
|
|
14
|
+
* which every builder relays, end with what finalReport asks for (the field stays as it is).
|
|
15
|
+
*/
|
|
16
|
+
export declare function tellUserWithReport(tellUser: readonly string[], finalReport: readonly string[], dbRules?: readonly string[]): string[];
|
|
17
|
+
/**
|
|
18
|
+
* R6 Codex T3: the heading said "one row per rule" and the builder, which reads only tellUser of
|
|
19
|
+
* the result and had read part of section 6, wrote a table of twelve rules of its own. The
|
|
20
|
+
* heading names the rules of the installed @dokebi/db AI.md (`### 6.N Title`), so each row has
|
|
21
|
+
* one to answer. No installed AI.md: none.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readDbPracticeRules(root: string): Promise<string[]>;
|
|
24
|
+
/**
|
|
25
|
+
* M34: a builder silenced background-timer on a database polling loop and check passed. verify ok
|
|
26
|
+
* asks the final message to list every silenced problem with its reason, so the person reading it
|
|
27
|
+
* sees each one (the markers stay legitimate for per-process upkeep).
|
|
28
|
+
*/
|
|
29
|
+
export declare function allowedReport(allowed: readonly AllowedMarker[]): string;
|
|
30
|
+
/**
|
|
31
|
+
* R2 Codex T2, T4, T5 (and R1 T1, T2): the builder relayed the headings verbatim, "the table below
|
|
32
|
+
* …", and put the table in a results file; it reads relay and tellUser, not finalReport. With
|
|
33
|
+
* finalReport lines, relay itself says that each heading's table or list follows it in the message.
|
|
34
|
+
* R4 Claude T4 copied the other lines and wrote its own headings for these two (`## Database
|
|
35
|
+
* practice (…)`): relay called them "a heading". They are lines to copy like the others.
|
|
36
|
+
*/
|
|
37
|
+
export declare const REPORT_RELAY: Readonly<{
|
|
38
|
+
tellUser: 'verbatim';
|
|
39
|
+
instruction: "Copy these lines unchanged into your final message to the user: do not translate, reword or summarize them. You may add your own summary after them. The lines that start with Database practice and Silenced check rules are copied word for word too (they are lines of this list, not headings to write in your own words), and right under each of them you write the table or list it announces, in the same final message, as finalReport says; a results file or a link to one does not replace it.";
|
|
40
|
+
}>;
|
|
41
|
+
export declare const DB_PRACTICE_REPORT = "Database practice (@dokebi/db is installed): your final message contains a table with one row per rule of section 6 of node_modules/@dokebi/db/AI.md: the rule, the queries or jobs it applies to, the measurement (rows read, rate, refusals) and the verdict (pass, or the fix you made). A rule you did not measure is not done; a separate results file does not replace the table in the message.";
|
|
1
42
|
export interface VerifyCheck {
|
|
2
|
-
readonly name: 'doctor' | 'check' | 'typecheck' | 'build' | 'preflight' | 'start' | 'http' | 'stop';
|
|
43
|
+
readonly name: 'doctor' | 'check' | 'typecheck' | 'build' | 'test' | 'preflight' | 'start' | 'http' | 'stop';
|
|
3
44
|
readonly ok: boolean;
|
|
4
45
|
readonly skipped?: string;
|
|
5
46
|
readonly ms: number;
|
|
@@ -18,16 +59,63 @@ export interface VerifyResult {
|
|
|
18
59
|
readonly urls: string[];
|
|
19
60
|
/** The listening addresses (the web listening line, else dokebi.yaml); HTTP checks connect here. */
|
|
20
61
|
readonly listen: string[];
|
|
62
|
+
/**
|
|
63
|
+
* The project's tests when package.json has a `test` script (D-0077 H): with node:test a result
|
|
64
|
+
* per test (`runner: 'node'`), otherwise the exit code and duration (`runner: 'other'`).
|
|
65
|
+
*/
|
|
66
|
+
readonly tests?: TestReport;
|
|
21
67
|
/**
|
|
22
68
|
* Only when ok and a recipe was applied (`.dokebi/recipe.json`): lines the agent's final
|
|
23
69
|
* message to the user must include — each choice and who made it (a default with its reason)
|
|
24
|
-
* and the notices, such as
|
|
70
|
+
* and the notices, such as how the forms send the CSRF token (AI-M03, D-0076 §6).
|
|
25
71
|
*/
|
|
26
72
|
readonly tellUser?: string[];
|
|
73
|
+
/** With tellUser: relay the lines verbatim (`{ tellUser: 'verbatim', instruction }`, AI-M05 F6). */
|
|
74
|
+
readonly relay?: {
|
|
75
|
+
readonly tellUser: 'verbatim';
|
|
76
|
+
readonly instruction: string;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Only when ok (D-0105 §4): what the final message must also contain, written by the agent
|
|
80
|
+
* (not copied): with @dokebi/db, the section 6 rule-by-rule measurement table.
|
|
81
|
+
*/
|
|
82
|
+
readonly finalReport?: string[];
|
|
83
|
+
/**
|
|
84
|
+
* When the preflight found the web port taken (AI-M04 F5): the question for the user, like the
|
|
85
|
+
* recipe preflight (`waitForUser`, `question`, `next`; the problem has `alternative`).
|
|
86
|
+
*/
|
|
87
|
+
readonly waitForUser?: boolean;
|
|
88
|
+
readonly question?: string;
|
|
89
|
+
readonly next?: string;
|
|
90
|
+
/**
|
|
91
|
+
* R4CC-7: what the project has that the app verify started does not take, such as a
|
|
92
|
+
* DOKEBI_CONFIG line of .env (`ENV_DOKEBI_CONFIG_IGNORED`, `path: .env:<line>`, `hint`).
|
|
93
|
+
*/
|
|
94
|
+
readonly warnings?: Pick<EnvFileWarning, 'code' | 'path' | 'hint'>[];
|
|
95
|
+
}
|
|
96
|
+
/** One problem of verify's preflight. */
|
|
97
|
+
export interface PreflightProblem {
|
|
98
|
+
readonly code: string;
|
|
99
|
+
readonly message: string;
|
|
100
|
+
readonly fix: string;
|
|
101
|
+
/** PORT_IN_USE: the first free port after the taken one (found like the recipe preflight). */
|
|
102
|
+
readonly alternative?: number;
|
|
103
|
+
readonly waitForUser?: boolean;
|
|
104
|
+
readonly question?: string;
|
|
105
|
+
readonly next?: string;
|
|
27
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The preflight problem for a taken web port (AI-M04 F5), with what the recipe preflight gives:
|
|
109
|
+
* a free `alternative`, `waitForUser`, `question` and `next`. Who holds the port is not looked up
|
|
110
|
+
* (no `ps`); verify only knows it is not an app `dokebi start` runs for this project (that is
|
|
111
|
+
* ALREADY_RUNNING). `delegated`: the last recipe ran with --defaults (`.dokebi/recipe.json`).
|
|
112
|
+
*/
|
|
113
|
+
export declare function webPortProblem(port: number, delegated: boolean): Promise<PreflightProblem>;
|
|
28
114
|
/**
|
|
29
115
|
* Runs an npm script of the project; resolves with its exit code and the bounded, redacted end
|
|
30
|
-
* of its output (stdout and stderr in arrival order, `OutputTail` of npm-client).
|
|
116
|
+
* of its output (stdout and stderr in arrival order, `OutputTail` of npm-client). The script runs
|
|
117
|
+
* in its own process group and the time limit kills the whole group: killing npm alone left tsc
|
|
118
|
+
* or Vite running, and the pipes they hold kept the step open until they ended by themselves.
|
|
31
119
|
*/
|
|
32
120
|
export declare function npmScript(root: string, script: string, timeoutMs: number): Promise<{
|
|
33
121
|
code: number | null;
|
|
@@ -46,17 +134,38 @@ export interface HttpProbe {
|
|
|
46
134
|
readonly ok: boolean;
|
|
47
135
|
/** `other`: an answer without this run's token header — another server on the port. */
|
|
48
136
|
readonly instance?: 'ours' | 'other';
|
|
137
|
+
/**
|
|
138
|
+
* The identity probe (no readiness route and no app domain): `GET /` passes with any status
|
|
139
|
+
* below 500 that carries this run's header.
|
|
140
|
+
*/
|
|
141
|
+
readonly identity?: true;
|
|
142
|
+
/** A domain answered with 401/403; reachability passed, login was not tested. */
|
|
143
|
+
readonly accessDenied?: true;
|
|
49
144
|
readonly error?: string;
|
|
50
145
|
}
|
|
51
146
|
/**
|
|
52
147
|
* The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
|
|
53
148
|
* answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
|
|
54
149
|
* started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
|
|
150
|
+
* With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
|
|
151
|
+
* runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
|
|
152
|
+
* status below 500 that carries the token, a 404 included (the app may have no route at `/`).
|
|
153
|
+
* Domain probes also accept 401/403 from this instance, reported as accessDenied: true.
|
|
154
|
+
* These prove reachability only; readiness still requires 2xx/3xx and login needs app tests.
|
|
155
|
+
* Each request must complete within `timeoutMs` (ISSUE-0006 A09).
|
|
55
156
|
*/
|
|
56
157
|
export declare function httpChecks(base: string, paths: {
|
|
57
158
|
ready: string | null;
|
|
58
159
|
domains: readonly string[];
|
|
59
|
-
}, token: string, delayMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
|
|
160
|
+
}, token: string, delayMs?: number, timeoutMs?: number): Promise<Omit<VerifyCheck, 'name' | 'ms'>>;
|
|
161
|
+
/**
|
|
162
|
+
* While the app verify started runs: a SIGINT, SIGTERM or SIGHUP to verify (Ctrl+C, an agent's
|
|
163
|
+
* command time limit) stops that app first (stopStarted, forced after `limitMs`), then ends verify
|
|
164
|
+
* as the signal would have. Without it the background app outlived verify and held its port; the
|
|
165
|
+
* next verify then refused with ALREADY_RUNNING. Returns the function that removes the handlers.
|
|
166
|
+
*/
|
|
167
|
+
export declare function stopOnTermination(root: string, started: Pick<server.StartResult, 'pid' | 'logWriter'>, limitMs?: number): () => void;
|
|
60
168
|
export declare function verify(root: string, options?: {
|
|
61
169
|
waitMs?: number;
|
|
170
|
+
testTimeoutMs?: number;
|
|
62
171
|
}): Promise<VerifyResult>;
|