proteum 2.1.9 → 2.2.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/.codex/environments/environment.toml +11 -0
- package/AGENTS.md +25 -11
- package/README.md +19 -9
- package/agents/project/AGENTS.md +165 -120
- package/agents/project/CODING_STYLE.md +1 -1
- package/agents/project/app-root/AGENTS.md +16 -0
- package/agents/project/client/AGENTS.md +5 -5
- package/agents/project/client/pages/AGENTS.md +13 -13
- package/agents/project/diagnostics.md +19 -10
- package/agents/project/optimizations.md +5 -6
- package/agents/project/root/AGENTS.md +295 -0
- package/agents/project/server/routes/AGENTS.md +2 -2
- package/agents/project/server/services/AGENTS.md +4 -2
- package/agents/project/tests/AGENTS.md +2 -2
- package/cli/app/index.ts +31 -7
- package/cli/commands/configure.ts +226 -0
- package/cli/commands/dev.ts +0 -2
- package/cli/commands/diagnose.ts +33 -1
- package/cli/commands/explain.ts +1 -1
- package/cli/commands/migrate.ts +51 -0
- package/cli/commands/orient.ts +169 -0
- package/cli/commands/perf.ts +8 -1
- package/cli/commands/verify.ts +1003 -49
- package/cli/compiler/artifacts/manifest.ts +4 -4
- package/cli/compiler/artifacts/routing.ts +2 -2
- package/cli/compiler/artifacts/services.ts +12 -3
- package/cli/compiler/client/index.ts +65 -19
- package/cli/compiler/common/files/style.ts +47 -2
- package/cli/compiler/common/generatedRouteModules.ts +31 -38
- package/cli/compiler/common/index.ts +10 -0
- package/cli/compiler/common/proteumManifest.ts +1 -0
- package/cli/compiler/server/index.ts +34 -9
- package/cli/context.ts +6 -1
- package/cli/index.ts +7 -8
- package/cli/migrate/pageContract.ts +516 -0
- package/cli/paths.ts +47 -6
- package/cli/presentation/commands.ts +100 -10
- package/cli/presentation/devSession.ts +4 -6
- package/cli/presentation/help.ts +2 -2
- package/cli/presentation/ink.ts +10 -5
- package/cli/presentation/welcome.ts +2 -4
- package/cli/runtime/commands.ts +94 -1
- package/cli/scaffold/index.ts +2 -2
- package/cli/scaffold/templates.ts +4 -2
- package/cli/utils/agents.ts +273 -58
- package/client/dev/profiler/index.tsx +3 -2
- package/client/router.ts +10 -2
- package/client/services/router/index.tsx +6 -22
- package/common/dev/connect.ts +20 -4
- package/common/dev/console.ts +7 -0
- package/common/dev/contractsDoctor.ts +354 -0
- package/common/dev/diagnostics.ts +10 -7
- package/common/dev/inspection.ts +830 -38
- package/common/dev/performance.ts +19 -5
- package/common/dev/profiler.ts +1 -0
- package/common/dev/proteumManifest.ts +5 -4
- package/common/dev/requestTrace.ts +12 -1
- package/common/router/contracts.ts +8 -11
- package/common/router/index.ts +2 -2
- package/common/router/pageData.ts +72 -0
- package/common/router/register.ts +10 -46
- package/common/router/response/page.ts +28 -16
- package/docs/dev-sessions.md +8 -4
- package/docs/diagnostics.md +77 -11
- package/docs/migrate-from-2.1.3.md +388 -0
- package/docs/request-tracing.md +25 -6
- package/package.json +6 -1
- package/scripts/update-codex-agents.ts +2 -2
- package/server/app/container/console/index.ts +11 -1
- package/server/app/container/trace/index.ts +117 -0
- package/server/app/devDiagnostics.ts +1 -1
- package/server/app/index.ts +5 -1
- package/server/services/auth/index.ts +9 -0
- package/server/services/router/index.ts +64 -14
- package/server/services/router/request/api.ts +7 -1
- package/server/services/router/response/index.ts +8 -28
- package/types/global/vendors.d.ts +12 -0
- package/types/vendors.d.ts +12 -0
- package/common/router/pageSetup.ts +0 -51
package/cli/commands/verify.ts
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import fs from 'fs';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import got, { type Method } from 'got';
|
|
3
4
|
import path from 'path';
|
|
4
|
-
|
|
5
|
-
import got from 'got';
|
|
6
5
|
import { UsageError } from 'clipanion';
|
|
7
6
|
|
|
8
7
|
import cli from '..';
|
|
8
|
+
import Compiler from '../compiler';
|
|
9
|
+
import Paths from '../paths';
|
|
10
|
+
import { readProteumManifest } from '../compiler/common/proteumManifest';
|
|
11
|
+
import { buildContractsDoctorResponse } from '@common/dev/contractsDoctor';
|
|
12
|
+
import { buildDoctorResponse, type TDoctorResponse } from '@common/dev/diagnostics';
|
|
13
|
+
import { buildOrientationResponse, type TDiagnoseChainItem, type TDiagnoseResponse, type TOrientResponse } from '@common/dev/inspection';
|
|
14
|
+
import type { TProteumManifest, TProteumManifestDiagnostic } from '@common/dev/proteumManifest';
|
|
15
|
+
import type { TDevCommandRunResponse } from '@common/dev/commands';
|
|
16
|
+
import type { TDevSessionErrorResponse, TDevSessionStartResponse } from '@common/dev/session';
|
|
17
|
+
|
|
18
|
+
type TVerifySeverity = 'error' | 'warning';
|
|
19
|
+
type TVerifyStepStatus = 'failed' | 'info' | 'passed';
|
|
20
|
+
|
|
21
|
+
type TVerifyFinding = {
|
|
22
|
+
severity: TVerifySeverity;
|
|
23
|
+
blocking: boolean;
|
|
24
|
+
code: string;
|
|
25
|
+
message: string;
|
|
26
|
+
source: 'browser' | 'contracts' | 'doctor' | 'framework-change' | 'request' | 'command';
|
|
27
|
+
filepath?: string;
|
|
28
|
+
sourceLocation?: { line?: number; column?: number };
|
|
29
|
+
relatedFilepaths?: string[];
|
|
30
|
+
details?: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type TVerifyStep = {
|
|
34
|
+
label: string;
|
|
35
|
+
status: TVerifyStepStatus;
|
|
36
|
+
details: string[];
|
|
37
|
+
};
|
|
9
38
|
|
|
10
39
|
type TVerifyAppResult = {
|
|
11
40
|
appRoot: string;
|
|
@@ -20,7 +49,19 @@ type TVerifyAppResult = {
|
|
|
20
49
|
|
|
21
50
|
type TVerifyResult = {
|
|
22
51
|
action: string;
|
|
23
|
-
|
|
52
|
+
target?: string;
|
|
53
|
+
orientation?: TOrientResponse;
|
|
54
|
+
introducedFindings: TVerifyFinding[];
|
|
55
|
+
preExistingFindings: TVerifyFinding[];
|
|
56
|
+
verificationSteps: TVerifyStep[];
|
|
57
|
+
result: {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
strictGlobal: boolean;
|
|
60
|
+
introducedBlockingFindings: number;
|
|
61
|
+
preExistingBlockingFindings: number;
|
|
62
|
+
blockingFindings: number;
|
|
63
|
+
};
|
|
64
|
+
apps?: TVerifyAppResult[];
|
|
24
65
|
};
|
|
25
66
|
|
|
26
67
|
type TEnsureServerResult =
|
|
@@ -35,27 +76,126 @@ type TVerifyAppConfig = {
|
|
|
35
76
|
route: string;
|
|
36
77
|
};
|
|
37
78
|
|
|
79
|
+
type TBrowserVerificationResult = {
|
|
80
|
+
runId: string;
|
|
81
|
+
workspaceRoot: string;
|
|
82
|
+
url: string;
|
|
83
|
+
title: string;
|
|
84
|
+
statusCode?: number;
|
|
85
|
+
consoleMessages: Array<{ type: string; text: string }>;
|
|
86
|
+
pageErrors: string[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type TVerifyPlaywrightCookie = {
|
|
90
|
+
name: string;
|
|
91
|
+
value: string;
|
|
92
|
+
url: string;
|
|
93
|
+
expires: number;
|
|
94
|
+
httpOnly: boolean;
|
|
95
|
+
secure: boolean;
|
|
96
|
+
sameSite: 'Lax';
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type TVerifyBrowserWorkspace = {
|
|
100
|
+
runId: string;
|
|
101
|
+
workspaceRoot: string;
|
|
102
|
+
outputDir: string;
|
|
103
|
+
profileDir: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
38
106
|
const defaultApps = {
|
|
39
107
|
crosspath: '/Users/gaetan/Desktop/Projets/crosspath/platform',
|
|
40
|
-
product: '/Users/gaetan/Desktop/Projets/unique.domains/product',
|
|
41
|
-
website: '/Users/gaetan/Desktop/Projets/unique.domains/website',
|
|
108
|
+
product: '/Users/gaetan/Desktop/Projets/unique.domains/platform/apps/product',
|
|
109
|
+
website: '/Users/gaetan/Desktop/Projets/unique.domains/platform/apps/website',
|
|
42
110
|
};
|
|
43
111
|
|
|
44
112
|
const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '');
|
|
45
|
-
const
|
|
113
|
+
const normalizeFilepath = (value: string) => value.replace(/\\/g, '/');
|
|
46
114
|
const dedupe = <TValue>(values: TValue[]) => [...new Set(values)];
|
|
115
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
116
|
const createLocalBaseUrl = (port: number) => `http://localhost:${port}`;
|
|
117
|
+
const browserLockPattern = /lock|singleton/i;
|
|
48
118
|
const getBaseUrlCandidates = (port: number) =>
|
|
49
119
|
dedupe([createLocalBaseUrl(port), `http://127.0.0.1:${port}`, `http://[::1]:${port}`]);
|
|
120
|
+
const createBrowserRunId = (now = new Date()) => {
|
|
121
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, '');
|
|
122
|
+
const suffix = Math.random().toString(36).slice(2, 8);
|
|
123
|
+
|
|
124
|
+
return `${date}-${suffix}`;
|
|
125
|
+
};
|
|
50
126
|
|
|
51
|
-
const
|
|
127
|
+
const createBrowserWorkspace = ({
|
|
128
|
+
appRoot,
|
|
129
|
+
runId = createBrowserRunId(),
|
|
130
|
+
}: {
|
|
131
|
+
appRoot: string;
|
|
132
|
+
runId?: string;
|
|
133
|
+
}): TVerifyBrowserWorkspace => {
|
|
134
|
+
const workspaceRoot = path.join(appRoot, 'var', 'proteum', 'browser', runId);
|
|
135
|
+
const outputDir = path.join(workspaceRoot, 'output');
|
|
136
|
+
const profileDir = path.join(workspaceRoot, 'profile');
|
|
137
|
+
|
|
138
|
+
fs.ensureDirSync(outputDir);
|
|
139
|
+
fs.ensureDirSync(profileDir);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
runId,
|
|
143
|
+
workspaceRoot,
|
|
144
|
+
outputDir,
|
|
145
|
+
profileDir,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const removeBrowserLocks = (currentPath: string) => {
|
|
150
|
+
if (!fs.existsSync(currentPath)) return;
|
|
151
|
+
|
|
152
|
+
for (const dirent of fs.readdirSync(currentPath, { withFileTypes: true })) {
|
|
153
|
+
const entryPath = path.join(currentPath, dirent.name);
|
|
154
|
+
|
|
155
|
+
if (dirent.isDirectory()) {
|
|
156
|
+
removeBrowserLocks(entryPath);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (browserLockPattern.test(dirent.name)) fs.removeSync(entryPath);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const cleanupBrowserRunLocks = async (workspaceRoot: string) => {
|
|
165
|
+
try {
|
|
166
|
+
removeBrowserLocks(workspaceRoot);
|
|
167
|
+
} catch {
|
|
168
|
+
// Best-effort cleanup for stale browser locks.
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const getRouterPortFromManifestFile = (manifestFilepath: string) => {
|
|
173
|
+
if (!fs.existsSync(manifestFilepath)) return undefined;
|
|
174
|
+
|
|
175
|
+
const manifest = fs.readJsonSync(manifestFilepath, { throws: false }) as
|
|
176
|
+
| { env?: { resolved?: { routerPort?: number } } }
|
|
177
|
+
| undefined;
|
|
178
|
+
const port = manifest?.env?.resolved?.routerPort;
|
|
179
|
+
|
|
180
|
+
if (typeof port !== 'number' || port <= 0) return undefined;
|
|
181
|
+
|
|
182
|
+
return port;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const fetchJson = async <TResponse>(baseUrl: string, pathname: string, options?: { json?: object; method?: 'GET' | 'POST' }) => {
|
|
52
186
|
const response = await got(`${normalizeBaseUrl(baseUrl)}${pathname}`, {
|
|
187
|
+
method: options?.method || 'GET',
|
|
188
|
+
json: options?.json,
|
|
53
189
|
responseType: 'json',
|
|
54
190
|
retry: { limit: 0 },
|
|
55
191
|
throwHttpErrors: false,
|
|
56
192
|
});
|
|
57
193
|
|
|
58
|
-
if (response.statusCode >= 400)
|
|
194
|
+
if (response.statusCode >= 400) {
|
|
195
|
+
const body = response.body as { error?: string } | undefined;
|
|
196
|
+
throw new UsageError(body?.error || `Request ${pathname} failed with status ${response.statusCode}.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
59
199
|
return response.body as TResponse;
|
|
60
200
|
};
|
|
61
201
|
|
|
@@ -126,6 +266,771 @@ const ensureServer = async ({
|
|
|
126
266
|
}
|
|
127
267
|
};
|
|
128
268
|
|
|
269
|
+
const resolveLocalManifest = async () => {
|
|
270
|
+
const compiler = new Compiler('dev');
|
|
271
|
+
await compiler.refreshGeneratedTypings();
|
|
272
|
+
return readProteumManifest(cli.paths.appRoot);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const resolveOrientation = async (query: string) => {
|
|
276
|
+
const manifest = await resolveLocalManifest();
|
|
277
|
+
return {
|
|
278
|
+
manifest,
|
|
279
|
+
orientation: buildOrientationResponse(manifest, query),
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const collectRelevantFilepaths = ({
|
|
284
|
+
manifest,
|
|
285
|
+
orientation,
|
|
286
|
+
chain,
|
|
287
|
+
}: {
|
|
288
|
+
manifest: TProteumManifest;
|
|
289
|
+
orientation: TOrientResponse;
|
|
290
|
+
chain?: TDiagnoseChainItem[];
|
|
291
|
+
}) => {
|
|
292
|
+
const filepaths = new Set<string>();
|
|
293
|
+
|
|
294
|
+
for (const match of orientation.owner.matches) {
|
|
295
|
+
if (match.source.filepath) filepaths.add(normalizeFilepath(path.resolve(match.source.filepath)));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const item of chain || []) {
|
|
299
|
+
if (item.source?.filepath) filepaths.add(normalizeFilepath(path.resolve(item.source.filepath)));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (orientation.connected.imports.length > 0 || orientation.connected.producers.length > 0) {
|
|
303
|
+
filepaths.add(normalizeFilepath(path.resolve(manifest.app.setupFilepath)));
|
|
304
|
+
for (const producer of orientation.connected.producers) {
|
|
305
|
+
if (producer.cachedContractFilepath) filepaths.add(normalizeFilepath(path.resolve(producer.cachedContractFilepath)));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return filepaths;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const diagnosticTouchesRelevantFiles = (diagnostic: TProteumManifestDiagnostic, relevantFilepaths: Set<string>) => {
|
|
313
|
+
const diagnosticFilepath = normalizeFilepath(path.resolve(diagnostic.filepath));
|
|
314
|
+
if (relevantFilepaths.has(diagnosticFilepath)) return true;
|
|
315
|
+
|
|
316
|
+
return (diagnostic.relatedFilepaths || []).some((filepath) => relevantFilepaths.has(normalizeFilepath(path.resolve(filepath))));
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const toFindingFromDiagnostic = (
|
|
320
|
+
diagnostic: TProteumManifestDiagnostic,
|
|
321
|
+
source: 'contracts' | 'doctor',
|
|
322
|
+
): TVerifyFinding => ({
|
|
323
|
+
severity: diagnostic.level === 'error' ? 'error' : 'warning',
|
|
324
|
+
blocking: diagnostic.level === 'error',
|
|
325
|
+
code: diagnostic.code,
|
|
326
|
+
message: diagnostic.message,
|
|
327
|
+
source,
|
|
328
|
+
filepath: diagnostic.filepath,
|
|
329
|
+
sourceLocation: diagnostic.sourceLocation,
|
|
330
|
+
relatedFilepaths: diagnostic.relatedFilepaths,
|
|
331
|
+
details: diagnostic.fixHint ? [`fix=${diagnostic.fixHint}`] : undefined,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const classifyDiagnostics = ({
|
|
335
|
+
contracts,
|
|
336
|
+
doctor,
|
|
337
|
+
manifest,
|
|
338
|
+
orientation,
|
|
339
|
+
chain,
|
|
340
|
+
}: {
|
|
341
|
+
contracts: TDoctorResponse;
|
|
342
|
+
doctor: TDoctorResponse;
|
|
343
|
+
manifest: TProteumManifest;
|
|
344
|
+
orientation: TOrientResponse;
|
|
345
|
+
chain?: TDiagnoseChainItem[];
|
|
346
|
+
}) => {
|
|
347
|
+
const relevantFilepaths = collectRelevantFilepaths({ manifest, orientation, chain });
|
|
348
|
+
const introducedFindings: TVerifyFinding[] = [];
|
|
349
|
+
const preExistingFindings: TVerifyFinding[] = [];
|
|
350
|
+
|
|
351
|
+
const classify = (diagnostics: TProteumManifestDiagnostic[], source: 'contracts' | 'doctor') => {
|
|
352
|
+
for (const diagnostic of diagnostics) {
|
|
353
|
+
const finding = toFindingFromDiagnostic(diagnostic, source);
|
|
354
|
+
if (diagnosticTouchesRelevantFiles(diagnostic, relevantFilepaths)) introducedFindings.push(finding);
|
|
355
|
+
else preExistingFindings.push(finding);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
classify(doctor.diagnostics, 'doctor');
|
|
360
|
+
classify(contracts.diagnostics, 'contracts');
|
|
361
|
+
|
|
362
|
+
return { introducedFindings, preExistingFindings };
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const finalizeResult = ({
|
|
366
|
+
action,
|
|
367
|
+
apps,
|
|
368
|
+
introducedFindings,
|
|
369
|
+
orientation,
|
|
370
|
+
preExistingFindings,
|
|
371
|
+
strictGlobal,
|
|
372
|
+
target,
|
|
373
|
+
verificationSteps,
|
|
374
|
+
}: {
|
|
375
|
+
action: string;
|
|
376
|
+
target?: string;
|
|
377
|
+
orientation?: TOrientResponse;
|
|
378
|
+
apps?: TVerifyAppResult[];
|
|
379
|
+
introducedFindings: TVerifyFinding[];
|
|
380
|
+
preExistingFindings: TVerifyFinding[];
|
|
381
|
+
verificationSteps: TVerifyStep[];
|
|
382
|
+
strictGlobal: boolean;
|
|
383
|
+
}): TVerifyResult => {
|
|
384
|
+
const introducedBlockingFindings = introducedFindings.filter((finding) => finding.blocking).length;
|
|
385
|
+
const preExistingBlockingFindings = preExistingFindings.filter((finding) => finding.blocking).length;
|
|
386
|
+
const ok = introducedBlockingFindings === 0 && (!strictGlobal || preExistingBlockingFindings === 0);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
action,
|
|
390
|
+
...(target ? { target } : {}),
|
|
391
|
+
...(orientation ? { orientation } : {}),
|
|
392
|
+
...(apps ? { apps } : {}),
|
|
393
|
+
introducedFindings,
|
|
394
|
+
preExistingFindings,
|
|
395
|
+
verificationSteps,
|
|
396
|
+
result: {
|
|
397
|
+
ok,
|
|
398
|
+
strictGlobal,
|
|
399
|
+
introducedBlockingFindings,
|
|
400
|
+
preExistingBlockingFindings,
|
|
401
|
+
blockingFindings: introducedBlockingFindings + preExistingBlockingFindings,
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const renderFindings = (title: string, findings: TVerifyFinding[]) =>
|
|
407
|
+
findings.length === 0
|
|
408
|
+
? [title, '- none'].join('\n')
|
|
409
|
+
: [
|
|
410
|
+
title,
|
|
411
|
+
...findings.map(
|
|
412
|
+
(finding) =>
|
|
413
|
+
`- [${finding.severity}] ${finding.code} ${finding.message}${finding.filepath ? ` source=${finding.filepath}${finding.sourceLocation?.line ? `:${finding.sourceLocation.line}` : ''}${finding.sourceLocation?.column ? `:${finding.sourceLocation.column}` : ''}` : ''}${finding.details && finding.details.length > 0 ? ` details=${finding.details.join(', ')}` : ''}`,
|
|
414
|
+
),
|
|
415
|
+
].join('\n');
|
|
416
|
+
|
|
417
|
+
const renderSteps = (steps: TVerifyStep[]) =>
|
|
418
|
+
[
|
|
419
|
+
'Verification Steps',
|
|
420
|
+
...(steps.length === 0
|
|
421
|
+
? ['- none']
|
|
422
|
+
: steps.map((step) => `- [${step.status}] ${step.label}${step.details.length > 0 ? ` | ${step.details.join(', ')}` : ''}`)),
|
|
423
|
+
].join('\n');
|
|
424
|
+
|
|
425
|
+
const renderFrameworkApps = (apps: TVerifyAppResult[]) =>
|
|
426
|
+
apps
|
|
427
|
+
.flatMap((app) => [
|
|
428
|
+
'',
|
|
429
|
+
`${app.name}`,
|
|
430
|
+
`- root=${app.appRoot}`,
|
|
431
|
+
`- baseUrl=${app.baseUrl}`,
|
|
432
|
+
`- startup=${app.startup}`,
|
|
433
|
+
`- page=${app.page.statusCode} ${app.page.url}`,
|
|
434
|
+
`- explain routes=${app.explain.routes} controllers=${app.explain.controllers} commands=${app.explain.commands}`,
|
|
435
|
+
`- doctor errors=${app.doctor.errors} warnings=${app.doctor.warnings}`,
|
|
436
|
+
`- contracts errors=${app.contracts.errors} warnings=${app.contracts.warnings}`,
|
|
437
|
+
])
|
|
438
|
+
.join('\n');
|
|
439
|
+
|
|
440
|
+
const renderHuman = (result: TVerifyResult) =>
|
|
441
|
+
[
|
|
442
|
+
`Proteum verify ${result.action}${result.target ? ` ${result.target}` : ''}`,
|
|
443
|
+
...(result.orientation
|
|
444
|
+
? [
|
|
445
|
+
`- appRoot=${result.orientation.app.appRoot}`,
|
|
446
|
+
`- repoRoot=${result.orientation.app.repoRoot}`,
|
|
447
|
+
`- owner=${result.orientation.owner.matches[0]?.label || 'none'}`,
|
|
448
|
+
]
|
|
449
|
+
: []),
|
|
450
|
+
...(result.apps ? [renderFrameworkApps(result.apps)] : []),
|
|
451
|
+
'',
|
|
452
|
+
renderSteps(result.verificationSteps),
|
|
453
|
+
'',
|
|
454
|
+
renderFindings('Introduced Findings', result.introducedFindings),
|
|
455
|
+
'',
|
|
456
|
+
renderFindings('Pre-existing Findings', result.preExistingFindings),
|
|
457
|
+
'',
|
|
458
|
+
`Result\n- ok=${result.result.ok}\n- strictGlobal=${result.result.strictGlobal}\n- introducedBlockingFindings=${result.result.introducedBlockingFindings}\n- preExistingBlockingFindings=${result.result.preExistingBlockingFindings}`,
|
|
459
|
+
].join('\n');
|
|
460
|
+
|
|
461
|
+
const getSessionErrorMessage = (body: TDevSessionErrorResponse | object | string | undefined, statusCode: number) => {
|
|
462
|
+
if (typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string') {
|
|
463
|
+
return body.error;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return `Session request failed with status ${statusCode}.`;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const requestSession = async ({ baseUrl, email, role }: { baseUrl: string; email: string; role: string }) => {
|
|
470
|
+
const response = await got(`${normalizeBaseUrl(baseUrl)}/__proteum/session/start`, {
|
|
471
|
+
method: 'POST',
|
|
472
|
+
json: role ? { email, role } : { email },
|
|
473
|
+
responseType: 'json',
|
|
474
|
+
throwHttpErrors: false,
|
|
475
|
+
retry: { limit: 0 },
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (response.statusCode >= 400) {
|
|
479
|
+
throw new UsageError(
|
|
480
|
+
getSessionErrorMessage(response.body as TDevSessionErrorResponse | object | string | undefined, response.statusCode),
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const payload = response.body as TDevSessionStartResponse;
|
|
485
|
+
const expires = Math.floor(Date.parse(payload.session.expiresAt) / 1000);
|
|
486
|
+
const secure = new URL(baseUrl).protocol === 'https:';
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
cookieHeader: `${payload.session.cookieName}=${payload.session.token}`,
|
|
490
|
+
playwrightCookies: [
|
|
491
|
+
{
|
|
492
|
+
name: payload.session.cookieName,
|
|
493
|
+
value: payload.session.token,
|
|
494
|
+
url: normalizeBaseUrl(baseUrl),
|
|
495
|
+
expires,
|
|
496
|
+
httpOnly: false,
|
|
497
|
+
secure,
|
|
498
|
+
sameSite: 'Lax',
|
|
499
|
+
} satisfies TVerifyPlaywrightCookie,
|
|
500
|
+
],
|
|
501
|
+
};
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const hitRequest = async ({
|
|
505
|
+
baseUrl,
|
|
506
|
+
cookieHeader,
|
|
507
|
+
dataJson,
|
|
508
|
+
method,
|
|
509
|
+
requestPath,
|
|
510
|
+
}: {
|
|
511
|
+
baseUrl: string;
|
|
512
|
+
cookieHeader?: string;
|
|
513
|
+
dataJson?: unknown;
|
|
514
|
+
method: Method;
|
|
515
|
+
requestPath: string;
|
|
516
|
+
}) => {
|
|
517
|
+
const targetUrl = requestPath.startsWith('http://') || requestPath.startsWith('https://') ? requestPath : `${baseUrl}${requestPath}`;
|
|
518
|
+
const headers = {
|
|
519
|
+
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
|
520
|
+
...(dataJson !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
521
|
+
};
|
|
522
|
+
const response = await got(targetUrl, {
|
|
523
|
+
body: dataJson !== undefined ? JSON.stringify(dataJson) : undefined,
|
|
524
|
+
followRedirect: false,
|
|
525
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
526
|
+
method,
|
|
527
|
+
retry: { limit: 0 },
|
|
528
|
+
throwHttpErrors: false,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
return { statusCode: response.statusCode, url: targetUrl };
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const armTrace = async (baseUrl: string) => {
|
|
535
|
+
await fetchJson(baseUrl, '/__proteum/trace/arm', { method: 'POST', json: { capture: 'deep' } });
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const requestDiagnose = async (baseUrl: string, target: string) => {
|
|
539
|
+
const params = new URLSearchParams({ query: target });
|
|
540
|
+
if (target.startsWith('/')) params.set('path', target);
|
|
541
|
+
return await fetchJson<TDiagnoseResponse>(baseUrl, `/__proteum/diagnose?${params.toString()}`);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const requestCommandRun = async (baseUrl: string, commandPath: string) =>
|
|
545
|
+
await fetchJson<TDevCommandRunResponse>(baseUrl, '/__proteum/commands/run', {
|
|
546
|
+
method: 'POST',
|
|
547
|
+
json: { path: commandPath },
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const parseDataJson = () => {
|
|
551
|
+
if (typeof cli.args.dataJson !== 'string' || !cli.args.dataJson.trim()) return undefined;
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
return JSON.parse(cli.args.dataJson);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
throw new UsageError(`Invalid --data-json payload: ${error instanceof Error ? error.message : String(error)}`);
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const resolveFocusedPort = (manifest: TProteumManifest) => {
|
|
561
|
+
if (typeof cli.args.port === 'string' && cli.args.port.trim()) {
|
|
562
|
+
const parsedPort = Number.parseInt(cli.args.port.trim(), 10);
|
|
563
|
+
if (!Number.isInteger(parsedPort) || parsedPort <= 0) throw new UsageError(`Invalid --port value "${cli.args.port}".`);
|
|
564
|
+
return parsedPort;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return getRouterPortFromManifestFile(path.join(manifest.app.root, '.proteum', 'manifest.json')) || manifest.env.resolved.routerPort;
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const ensureFocusedServer = async (manifest: TProteumManifest) => {
|
|
571
|
+
const explicitUrl = typeof cli.args.url === 'string' && cli.args.url.trim();
|
|
572
|
+
if (explicitUrl) {
|
|
573
|
+
await fetchJson(explicitUrl, '/__proteum/explain?section=app');
|
|
574
|
+
return { baseUrl: normalizeBaseUrl(explicitUrl), startup: 'reused' as const };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return await ensureServer({
|
|
578
|
+
appRoot: manifest.app.root,
|
|
579
|
+
port: resolveFocusedPort(manifest),
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const runBrowserVerification = async ({
|
|
584
|
+
appRoot,
|
|
585
|
+
baseUrl,
|
|
586
|
+
playwrightCookies,
|
|
587
|
+
target,
|
|
588
|
+
}: {
|
|
589
|
+
appRoot: string;
|
|
590
|
+
baseUrl: string;
|
|
591
|
+
target: string;
|
|
592
|
+
playwrightCookies?: Array<{
|
|
593
|
+
name: string;
|
|
594
|
+
value: string;
|
|
595
|
+
url: string;
|
|
596
|
+
expires: number;
|
|
597
|
+
httpOnly: boolean;
|
|
598
|
+
secure: boolean;
|
|
599
|
+
sameSite: 'Lax';
|
|
600
|
+
}>;
|
|
601
|
+
}) => {
|
|
602
|
+
const paths = new Paths(appRoot, cli.paths.core.root);
|
|
603
|
+
let playwrightModulePath: string | undefined;
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
playwrightModulePath = paths.resolveRequest('playwright');
|
|
607
|
+
} catch (_error) {
|
|
608
|
+
try {
|
|
609
|
+
playwrightModulePath = paths.resolveRequest('@playwright/test');
|
|
610
|
+
} catch (_innerError) {}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!playwrightModulePath) {
|
|
614
|
+
throw new UsageError(
|
|
615
|
+
`Playwright is not installed in ${appRoot}. Install \`@playwright/test\` or \`playwright\`, then use \`npx playwright install chromium\`. Fallback: \`proteum verify request ${target}\`.`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const playwright = require(playwrightModulePath) as {
|
|
620
|
+
chromium?: {
|
|
621
|
+
launchPersistentContext: (
|
|
622
|
+
userDataDir: string,
|
|
623
|
+
options: { headless: boolean },
|
|
624
|
+
) => Promise<{
|
|
625
|
+
newPage: () => Promise<{
|
|
626
|
+
on: (event: string, listener: (...args: any[]) => void) => void;
|
|
627
|
+
goto: (url: string, options: { waitUntil: 'domcontentloaded' | 'load' }) => Promise<{ status: () => number } | null>;
|
|
628
|
+
title: () => Promise<string>;
|
|
629
|
+
screenshot: (options: { fullPage: boolean; path: string }) => Promise<void>;
|
|
630
|
+
waitForTimeout: (ms: number) => Promise<void>;
|
|
631
|
+
}>;
|
|
632
|
+
addCookies: (cookies: NonNullable<typeof playwrightCookies>) => Promise<void>;
|
|
633
|
+
close: () => Promise<void>;
|
|
634
|
+
}>;
|
|
635
|
+
};
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
if (!playwright.chromium?.launchPersistentContext) {
|
|
639
|
+
throw new UsageError(
|
|
640
|
+
`Resolved Playwright package at ${playwrightModulePath}, but Chromium is unavailable. Run \`npx playwright install chromium\` in ${appRoot}.`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const workspace = createBrowserWorkspace({ appRoot });
|
|
645
|
+
await cleanupBrowserRunLocks(workspace.workspaceRoot);
|
|
646
|
+
|
|
647
|
+
const targetUrl = target.startsWith('http://') || target.startsWith('https://') ? target : `${baseUrl}${target}`;
|
|
648
|
+
const consoleMessages: Array<{ type: string; text: string }> = [];
|
|
649
|
+
const pageErrors: string[] = [];
|
|
650
|
+
|
|
651
|
+
let browserContext:
|
|
652
|
+
| {
|
|
653
|
+
newPage: () => Promise<any>;
|
|
654
|
+
addCookies: (cookies: NonNullable<typeof playwrightCookies>) => Promise<void>;
|
|
655
|
+
close: () => Promise<void>;
|
|
656
|
+
}
|
|
657
|
+
| undefined;
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
browserContext = await playwright.chromium.launchPersistentContext(workspace.profileDir, { headless: true });
|
|
661
|
+
if (playwrightCookies && playwrightCookies.length > 0) await browserContext.addCookies(playwrightCookies);
|
|
662
|
+
|
|
663
|
+
const page = await browserContext.newPage();
|
|
664
|
+
page.on('console', (message: { type: () => string; text: () => string }) => {
|
|
665
|
+
consoleMessages.push({ type: message.type(), text: message.text() });
|
|
666
|
+
});
|
|
667
|
+
page.on('pageerror', (error: Error) => pageErrors.push(error.message));
|
|
668
|
+
|
|
669
|
+
const response = await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
|
670
|
+
await page.waitForTimeout(800);
|
|
671
|
+
const title = await page.title();
|
|
672
|
+
await page.screenshot({ fullPage: true, path: path.join(workspace.outputDir, 'page.png') });
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
runId: workspace.runId,
|
|
676
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
677
|
+
url: targetUrl,
|
|
678
|
+
title,
|
|
679
|
+
statusCode: response?.status(),
|
|
680
|
+
consoleMessages,
|
|
681
|
+
pageErrors,
|
|
682
|
+
} satisfies TBrowserVerificationResult;
|
|
683
|
+
} catch (error) {
|
|
684
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
685
|
+
if (message.includes('Executable') && message.includes('doesn')) {
|
|
686
|
+
throw new UsageError(
|
|
687
|
+
`Playwright is installed in ${appRoot}, but the Chromium browser is missing. Run \`npx playwright install chromium\` in that app. Fallback: \`proteum verify request ${target}\`.`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
throw error;
|
|
692
|
+
} finally {
|
|
693
|
+
await browserContext?.close();
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const runStaticOwnerVerify = async ({
|
|
698
|
+
manifest,
|
|
699
|
+
orientation,
|
|
700
|
+
}: {
|
|
701
|
+
manifest: TProteumManifest;
|
|
702
|
+
orientation: TOrientResponse;
|
|
703
|
+
}) => {
|
|
704
|
+
const doctor = buildDoctorResponse(manifest, false);
|
|
705
|
+
const contracts = buildContractsDoctorResponse(manifest, false);
|
|
706
|
+
const { introducedFindings, preExistingFindings } = classifyDiagnostics({
|
|
707
|
+
contracts,
|
|
708
|
+
doctor,
|
|
709
|
+
manifest,
|
|
710
|
+
orientation,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
return finalizeResult({
|
|
714
|
+
action: 'owner',
|
|
715
|
+
target: orientation.query,
|
|
716
|
+
orientation,
|
|
717
|
+
introducedFindings,
|
|
718
|
+
preExistingFindings,
|
|
719
|
+
strictGlobal: cli.args.strictGlobal === true,
|
|
720
|
+
verificationSteps: [
|
|
721
|
+
{
|
|
722
|
+
label: 'Orient Owner',
|
|
723
|
+
status: orientation.owner.matches.length > 0 ? 'passed' : 'failed',
|
|
724
|
+
details: [`owner=${orientation.owner.matches[0]?.label || 'none'}`],
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
label: 'Run Local Doctor',
|
|
728
|
+
status: 'passed',
|
|
729
|
+
details: [`doctor=${doctor.summary.errors} errors/${doctor.summary.warnings} warnings`],
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
label: 'Run Local Contracts',
|
|
733
|
+
status: 'passed',
|
|
734
|
+
details: [`contracts=${contracts.summary.errors} errors/${contracts.summary.warnings} warnings`],
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
});
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const runOwnerVerify = async (target: string) => {
|
|
741
|
+
const { manifest, orientation } = await resolveOrientation(target);
|
|
742
|
+
const topOwner = orientation.owner.matches[0];
|
|
743
|
+
if (!topOwner) return await runStaticOwnerVerify({ manifest, orientation });
|
|
744
|
+
|
|
745
|
+
if (topOwner.kind === 'command') {
|
|
746
|
+
const server = await ensureFocusedServer(manifest);
|
|
747
|
+
try {
|
|
748
|
+
const execution = (await requestCommandRun(server.baseUrl, topOwner.label)).execution;
|
|
749
|
+
const doctor = buildDoctorResponse(manifest, false);
|
|
750
|
+
const contracts = buildContractsDoctorResponse(manifest, false);
|
|
751
|
+
const findings = classifyDiagnostics({ contracts, doctor, manifest, orientation });
|
|
752
|
+
const introducedFindings = [...findings.introducedFindings];
|
|
753
|
+
if (execution.status === 'error') {
|
|
754
|
+
introducedFindings.push({
|
|
755
|
+
severity: 'error',
|
|
756
|
+
blocking: true,
|
|
757
|
+
code: 'command/runtime-error',
|
|
758
|
+
message: execution.errorMessage || `Command "${execution.command.path}" failed.`,
|
|
759
|
+
source: 'command',
|
|
760
|
+
filepath: execution.command.filepath,
|
|
761
|
+
sourceLocation: execution.command.sourceLocation,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return finalizeResult({
|
|
766
|
+
action: 'owner',
|
|
767
|
+
target,
|
|
768
|
+
orientation,
|
|
769
|
+
introducedFindings,
|
|
770
|
+
preExistingFindings: findings.preExistingFindings,
|
|
771
|
+
strictGlobal: cli.args.strictGlobal === true,
|
|
772
|
+
verificationSteps: [
|
|
773
|
+
{
|
|
774
|
+
label: 'Orient Owner',
|
|
775
|
+
status: 'passed',
|
|
776
|
+
details: [`owner=${topOwner.label}`, `kind=${topOwner.kind}`],
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
label: 'Ensure Dev Server',
|
|
780
|
+
status: 'passed',
|
|
781
|
+
details: [`startup=${server.startup}`, `baseUrl=${server.baseUrl}`],
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
label: 'Run Command',
|
|
785
|
+
status: execution.status === 'error' ? 'failed' : 'passed',
|
|
786
|
+
details: [`path=${execution.command.path}`, `status=${execution.status}`, `durationMs=${execution.durationMs}`],
|
|
787
|
+
},
|
|
788
|
+
],
|
|
789
|
+
});
|
|
790
|
+
} finally {
|
|
791
|
+
if ('close' in server) server.close();
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const requestTarget =
|
|
796
|
+
target.startsWith('/') ? target : topOwner.kind === 'route' || topOwner.kind === 'controller' ? topOwner.label : undefined;
|
|
797
|
+
if (requestTarget) {
|
|
798
|
+
return await runRequestVerify(requestTarget, orientation, manifest);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return await runStaticOwnerVerify({ manifest, orientation });
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
const runRequestVerify = async (target: string, existingOrientation?: TOrientResponse, existingManifest?: TProteumManifest) => {
|
|
805
|
+
const manifest = existingManifest || (await resolveLocalManifest());
|
|
806
|
+
const orientation = existingOrientation || buildOrientationResponse(manifest, target);
|
|
807
|
+
const server = await ensureFocusedServer(manifest);
|
|
808
|
+
const method = (typeof cli.args.method === 'string' && cli.args.method ? cli.args.method.trim().toUpperCase() : 'GET') as Method;
|
|
809
|
+
const dataJson = parseDataJson();
|
|
810
|
+
const sessionEmail = typeof cli.args.sessionEmail === 'string' ? cli.args.sessionEmail.trim() : '';
|
|
811
|
+
const sessionRole = typeof cli.args.sessionRole === 'string' ? cli.args.sessionRole.trim() : '';
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
await armTrace(server.baseUrl);
|
|
815
|
+
let session:
|
|
816
|
+
| {
|
|
817
|
+
cookieHeader: string;
|
|
818
|
+
playwrightCookies: Array<{
|
|
819
|
+
name: string;
|
|
820
|
+
value: string;
|
|
821
|
+
url: string;
|
|
822
|
+
expires: number;
|
|
823
|
+
httpOnly: boolean;
|
|
824
|
+
secure: boolean;
|
|
825
|
+
sameSite: 'Lax';
|
|
826
|
+
}>;
|
|
827
|
+
}
|
|
828
|
+
| undefined;
|
|
829
|
+
if (sessionEmail) {
|
|
830
|
+
session = await requestSession({ baseUrl: server.baseUrl, email: sessionEmail, role: sessionRole });
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const hit = await hitRequest({
|
|
834
|
+
baseUrl: server.baseUrl,
|
|
835
|
+
cookieHeader: session?.cookieHeader,
|
|
836
|
+
dataJson,
|
|
837
|
+
method,
|
|
838
|
+
requestPath: target,
|
|
839
|
+
});
|
|
840
|
+
const diagnose = await requestDiagnose(server.baseUrl, target);
|
|
841
|
+
const findings = classifyDiagnostics({
|
|
842
|
+
contracts: diagnose.contracts,
|
|
843
|
+
doctor: diagnose.doctor,
|
|
844
|
+
manifest,
|
|
845
|
+
orientation,
|
|
846
|
+
chain: diagnose.chain,
|
|
847
|
+
});
|
|
848
|
+
const introducedFindings = [...findings.introducedFindings];
|
|
849
|
+
|
|
850
|
+
if (hit.statusCode >= 400) {
|
|
851
|
+
introducedFindings.push({
|
|
852
|
+
severity: 'error',
|
|
853
|
+
blocking: true,
|
|
854
|
+
code: 'request/http-status',
|
|
855
|
+
message: `Request returned status ${hit.statusCode} for ${hit.url}.`,
|
|
856
|
+
source: 'request',
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (diagnose.request?.errorMessage) {
|
|
861
|
+
introducedFindings.push({
|
|
862
|
+
severity: 'error',
|
|
863
|
+
blocking: true,
|
|
864
|
+
code: 'request/trace-error',
|
|
865
|
+
message: diagnose.request.errorMessage,
|
|
866
|
+
source: 'request',
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return finalizeResult({
|
|
871
|
+
action: existingOrientation ? 'owner' : 'request',
|
|
872
|
+
target,
|
|
873
|
+
orientation,
|
|
874
|
+
introducedFindings,
|
|
875
|
+
preExistingFindings: findings.preExistingFindings,
|
|
876
|
+
strictGlobal: cli.args.strictGlobal === true,
|
|
877
|
+
verificationSteps: [
|
|
878
|
+
{
|
|
879
|
+
label: 'Orient Target',
|
|
880
|
+
status: 'passed',
|
|
881
|
+
details: [`owner=${orientation.owner.matches[0]?.label || 'none'}`],
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
label: 'Ensure Dev Server',
|
|
885
|
+
status: 'passed',
|
|
886
|
+
details: [`startup=${server.startup}`, `baseUrl=${server.baseUrl}`],
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
label: 'Arm Trace',
|
|
890
|
+
status: 'passed',
|
|
891
|
+
details: ['capture=deep'],
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
label: 'Hit Request',
|
|
895
|
+
status: hit.statusCode >= 400 ? 'failed' : 'passed',
|
|
896
|
+
details: [`method=${method}`, `status=${hit.statusCode}`, `url=${hit.url}`],
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
label: 'Collect Diagnose',
|
|
900
|
+
status: 'passed',
|
|
901
|
+
details: [
|
|
902
|
+
`doctor=${diagnose.doctor.summary.errors} errors/${diagnose.doctor.summary.warnings} warnings`,
|
|
903
|
+
`contracts=${diagnose.contracts.summary.errors} errors/${diagnose.contracts.summary.warnings} warnings`,
|
|
904
|
+
],
|
|
905
|
+
},
|
|
906
|
+
],
|
|
907
|
+
});
|
|
908
|
+
} finally {
|
|
909
|
+
if ('close' in server) server.close();
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const runBrowserVerify = async (target: string) => {
|
|
914
|
+
const manifest = await resolveLocalManifest();
|
|
915
|
+
const orientation = buildOrientationResponse(manifest, target);
|
|
916
|
+
const server = await ensureFocusedServer(manifest);
|
|
917
|
+
const sessionEmail = typeof cli.args.sessionEmail === 'string' ? cli.args.sessionEmail.trim() : '';
|
|
918
|
+
const sessionRole = typeof cli.args.sessionRole === 'string' ? cli.args.sessionRole.trim() : '';
|
|
919
|
+
|
|
920
|
+
try {
|
|
921
|
+
await armTrace(server.baseUrl);
|
|
922
|
+
let session:
|
|
923
|
+
| {
|
|
924
|
+
cookieHeader: string;
|
|
925
|
+
playwrightCookies: Array<{
|
|
926
|
+
name: string;
|
|
927
|
+
value: string;
|
|
928
|
+
url: string;
|
|
929
|
+
expires: number;
|
|
930
|
+
httpOnly: boolean;
|
|
931
|
+
secure: boolean;
|
|
932
|
+
sameSite: 'Lax';
|
|
933
|
+
}>;
|
|
934
|
+
}
|
|
935
|
+
| undefined;
|
|
936
|
+
if (sessionEmail) {
|
|
937
|
+
session = await requestSession({ baseUrl: server.baseUrl, email: sessionEmail, role: sessionRole });
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const browser = await runBrowserVerification({
|
|
941
|
+
appRoot: manifest.app.root,
|
|
942
|
+
baseUrl: server.baseUrl,
|
|
943
|
+
playwrightCookies: session?.playwrightCookies,
|
|
944
|
+
target,
|
|
945
|
+
});
|
|
946
|
+
const diagnose = await requestDiagnose(server.baseUrl, target);
|
|
947
|
+
const findings = classifyDiagnostics({
|
|
948
|
+
contracts: diagnose.contracts,
|
|
949
|
+
doctor: diagnose.doctor,
|
|
950
|
+
manifest,
|
|
951
|
+
orientation,
|
|
952
|
+
chain: diagnose.chain,
|
|
953
|
+
});
|
|
954
|
+
const introducedFindings = [...findings.introducedFindings];
|
|
955
|
+
|
|
956
|
+
if ((browser.statusCode || 0) >= 400) {
|
|
957
|
+
introducedFindings.push({
|
|
958
|
+
severity: 'error',
|
|
959
|
+
blocking: true,
|
|
960
|
+
code: 'browser/http-status',
|
|
961
|
+
message: `Browser navigation returned status ${browser.statusCode} for ${browser.url}.`,
|
|
962
|
+
source: 'browser',
|
|
963
|
+
details: [`workspace=${browser.workspaceRoot}`],
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
for (const consoleMessage of browser.consoleMessages) {
|
|
968
|
+
const isError = consoleMessage.type === 'error';
|
|
969
|
+
introducedFindings.push({
|
|
970
|
+
severity: isError ? 'error' : 'warning',
|
|
971
|
+
blocking: isError,
|
|
972
|
+
code: `browser/console-${consoleMessage.type}`,
|
|
973
|
+
message: consoleMessage.text,
|
|
974
|
+
source: 'browser',
|
|
975
|
+
details: [`workspace=${browser.workspaceRoot}`],
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
for (const pageError of browser.pageErrors) {
|
|
980
|
+
introducedFindings.push({
|
|
981
|
+
severity: 'error',
|
|
982
|
+
blocking: true,
|
|
983
|
+
code: 'browser/page-error',
|
|
984
|
+
message: pageError,
|
|
985
|
+
source: 'browser',
|
|
986
|
+
details: [`workspace=${browser.workspaceRoot}`],
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return finalizeResult({
|
|
991
|
+
action: 'browser',
|
|
992
|
+
target,
|
|
993
|
+
orientation,
|
|
994
|
+
introducedFindings,
|
|
995
|
+
preExistingFindings: findings.preExistingFindings,
|
|
996
|
+
strictGlobal: cli.args.strictGlobal === true,
|
|
997
|
+
verificationSteps: [
|
|
998
|
+
{
|
|
999
|
+
label: 'Orient Target',
|
|
1000
|
+
status: 'passed',
|
|
1001
|
+
details: [`owner=${orientation.owner.matches[0]?.label || 'none'}`],
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
label: 'Ensure Dev Server',
|
|
1005
|
+
status: 'passed',
|
|
1006
|
+
details: [`startup=${server.startup}`, `baseUrl=${server.baseUrl}`],
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
label: 'Arm Trace',
|
|
1010
|
+
status: 'passed',
|
|
1011
|
+
details: ['capture=deep'],
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
label: 'Run Browser Verification',
|
|
1015
|
+
status:
|
|
1016
|
+
browser.pageErrors.length > 0 || browser.consoleMessages.some((message) => message.type === 'error') ? 'failed' : 'passed',
|
|
1017
|
+
details: [`status=${browser.statusCode ?? 'unknown'}`, `title=${browser.title || 'untitled'}`, `workspace=${browser.workspaceRoot}`],
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
label: 'Collect Diagnose',
|
|
1021
|
+
status: 'passed',
|
|
1022
|
+
details: [
|
|
1023
|
+
`doctor=${diagnose.doctor.summary.errors} errors/${diagnose.doctor.summary.warnings} warnings`,
|
|
1024
|
+
`contracts=${diagnose.contracts.summary.errors} errors/${diagnose.contracts.summary.warnings} warnings`,
|
|
1025
|
+
],
|
|
1026
|
+
},
|
|
1027
|
+
],
|
|
1028
|
+
});
|
|
1029
|
+
} finally {
|
|
1030
|
+
if ('close' in server) server.close();
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
129
1034
|
const collectAppResult = async ({
|
|
130
1035
|
appRoot,
|
|
131
1036
|
baseUrl,
|
|
@@ -170,26 +1075,7 @@ const collectAppResult = async ({
|
|
|
170
1075
|
};
|
|
171
1076
|
};
|
|
172
1077
|
|
|
173
|
-
const
|
|
174
|
-
[
|
|
175
|
-
`Proteum verify ${result.action}`,
|
|
176
|
-
...result.apps.flatMap((app) => [
|
|
177
|
-
'',
|
|
178
|
-
`${app.name}`,
|
|
179
|
-
`- root=${app.appRoot}`,
|
|
180
|
-
`- baseUrl=${app.baseUrl}`,
|
|
181
|
-
`- startup=${app.startup}`,
|
|
182
|
-
`- page=${app.page.statusCode} ${app.page.url}`,
|
|
183
|
-
`- explain routes=${app.explain.routes} controllers=${app.explain.controllers} commands=${app.explain.commands}`,
|
|
184
|
-
`- doctor errors=${app.doctor.errors} warnings=${app.doctor.warnings}`,
|
|
185
|
-
`- contracts errors=${app.contracts.errors} warnings=${app.contracts.warnings}`,
|
|
186
|
-
]),
|
|
187
|
-
].join('\n');
|
|
188
|
-
|
|
189
|
-
export const run = async () => {
|
|
190
|
-
const action = typeof cli.args.action === 'string' && cli.args.action ? cli.args.action : 'framework-change';
|
|
191
|
-
if (action !== 'framework-change') throw new UsageError(`Unsupported verify action "${action}".`);
|
|
192
|
-
|
|
1078
|
+
const runFrameworkChangeVerify = async () => {
|
|
193
1079
|
const websiteRoute = typeof cli.args.route === 'string' && cli.args.route ? cli.args.route : '/';
|
|
194
1080
|
const apps = {
|
|
195
1081
|
crosspath: {
|
|
@@ -221,13 +1107,11 @@ export const run = async () => {
|
|
|
221
1107
|
const startedServers: Array<() => void> = [];
|
|
222
1108
|
|
|
223
1109
|
try {
|
|
224
|
-
const results: TVerifyAppResult[] = [];
|
|
225
|
-
|
|
226
1110
|
const productServer = await ensureServer({
|
|
227
1111
|
appRoot: apps.product.appRoot,
|
|
228
1112
|
port: apps.product.port,
|
|
229
1113
|
});
|
|
230
|
-
if (
|
|
1114
|
+
if ('close' in productServer) startedServers.push(productServer.close);
|
|
231
1115
|
|
|
232
1116
|
const websiteServer = await ensureServer({
|
|
233
1117
|
appRoot: apps.website.appRoot,
|
|
@@ -237,45 +1121,115 @@ export const run = async () => {
|
|
|
237
1121
|
},
|
|
238
1122
|
port: apps.website.port,
|
|
239
1123
|
});
|
|
240
|
-
if (
|
|
1124
|
+
if ('close' in websiteServer) startedServers.push(websiteServer.close);
|
|
241
1125
|
|
|
242
1126
|
const crosspathServer = await ensureServer({
|
|
243
1127
|
appRoot: apps.crosspath.appRoot,
|
|
244
1128
|
port: apps.crosspath.port,
|
|
245
1129
|
});
|
|
246
|
-
if (
|
|
1130
|
+
if ('close' in crosspathServer) startedServers.push(crosspathServer.close);
|
|
247
1131
|
|
|
248
|
-
results.
|
|
249
|
-
|
|
1132
|
+
const results = await Promise.all([
|
|
1133
|
+
collectAppResult({
|
|
250
1134
|
...apps.crosspath,
|
|
251
1135
|
baseUrl: crosspathServer.baseUrl,
|
|
252
1136
|
startup: crosspathServer.startup,
|
|
253
1137
|
}),
|
|
254
|
-
|
|
255
|
-
results.push(
|
|
256
|
-
await collectAppResult({
|
|
1138
|
+
collectAppResult({
|
|
257
1139
|
...apps.product,
|
|
258
1140
|
baseUrl: productServer.baseUrl,
|
|
259
1141
|
startup: productServer.startup,
|
|
260
1142
|
}),
|
|
261
|
-
|
|
262
|
-
results.push(
|
|
263
|
-
await collectAppResult({
|
|
1143
|
+
collectAppResult({
|
|
264
1144
|
...apps.website,
|
|
265
1145
|
baseUrl: websiteServer.baseUrl,
|
|
266
1146
|
startup: websiteServer.startup,
|
|
267
1147
|
}),
|
|
268
|
-
);
|
|
1148
|
+
]);
|
|
269
1149
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
1150
|
+
const introducedFindings: TVerifyFinding[] = [];
|
|
1151
|
+
for (const app of results) {
|
|
1152
|
+
if (app.page.statusCode >= 400) {
|
|
1153
|
+
introducedFindings.push({
|
|
1154
|
+
severity: 'error',
|
|
1155
|
+
blocking: true,
|
|
1156
|
+
code: 'framework-change/http-status',
|
|
1157
|
+
message: `${app.name} returned status ${app.page.statusCode} for ${app.page.url}.`,
|
|
1158
|
+
source: 'framework-change',
|
|
1159
|
+
filepath: app.appRoot,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
if (app.doctor.errors > 0) {
|
|
1163
|
+
introducedFindings.push({
|
|
1164
|
+
severity: 'error',
|
|
1165
|
+
blocking: true,
|
|
1166
|
+
code: 'framework-change/doctor-errors',
|
|
1167
|
+
message: `${app.name} reported ${app.doctor.errors} doctor errors.`,
|
|
1168
|
+
source: 'framework-change',
|
|
1169
|
+
filepath: app.appRoot,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
if (app.contracts.errors > 0) {
|
|
1173
|
+
introducedFindings.push({
|
|
1174
|
+
severity: 'error',
|
|
1175
|
+
blocking: true,
|
|
1176
|
+
code: 'framework-change/contracts-errors',
|
|
1177
|
+
message: `${app.name} reported ${app.contracts.errors} contract errors.`,
|
|
1178
|
+
source: 'framework-change',
|
|
1179
|
+
filepath: app.appRoot,
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
275
1182
|
}
|
|
276
1183
|
|
|
277
|
-
|
|
1184
|
+
return finalizeResult({
|
|
1185
|
+
action: 'framework-change',
|
|
1186
|
+
apps: results,
|
|
1187
|
+
introducedFindings,
|
|
1188
|
+
preExistingFindings: [],
|
|
1189
|
+
strictGlobal: cli.args.strictGlobal === true,
|
|
1190
|
+
verificationSteps: results.map((app) => ({
|
|
1191
|
+
label: `Check ${app.name}`,
|
|
1192
|
+
status: app.page.statusCode >= 400 || app.doctor.errors > 0 || app.contracts.errors > 0 ? 'failed' : 'passed',
|
|
1193
|
+
details: [
|
|
1194
|
+
`startup=${app.startup}`,
|
|
1195
|
+
`page=${app.page.statusCode}`,
|
|
1196
|
+
`doctor=${app.doctor.errors} errors/${app.doctor.warnings} warnings`,
|
|
1197
|
+
`contracts=${app.contracts.errors} errors/${app.contracts.warnings} warnings`,
|
|
1198
|
+
],
|
|
1199
|
+
})),
|
|
1200
|
+
});
|
|
278
1201
|
} finally {
|
|
279
1202
|
for (const close of startedServers.reverse()) close();
|
|
280
1203
|
}
|
|
281
1204
|
};
|
|
1205
|
+
|
|
1206
|
+
export const run = async () => {
|
|
1207
|
+
const action = typeof cli.args.action === 'string' && cli.args.action ? cli.args.action : 'framework-change';
|
|
1208
|
+
const target = typeof cli.args.target === 'string' ? cli.args.target.trim() : '';
|
|
1209
|
+
let result: TVerifyResult;
|
|
1210
|
+
|
|
1211
|
+
if (action === 'framework-change') {
|
|
1212
|
+
result = await runFrameworkChangeVerify();
|
|
1213
|
+
} else if (action === 'owner') {
|
|
1214
|
+
if (!target) throw new UsageError('`proteum verify owner` requires a query.');
|
|
1215
|
+
result = await runOwnerVerify(target);
|
|
1216
|
+
} else if (action === 'request') {
|
|
1217
|
+
if (!target) throw new UsageError('`proteum verify request` requires a path or absolute URL.');
|
|
1218
|
+
result = await runRequestVerify(target);
|
|
1219
|
+
} else if (action === 'browser') {
|
|
1220
|
+
if (!target) throw new UsageError('`proteum verify browser` requires a path or absolute URL.');
|
|
1221
|
+
result = await runBrowserVerify(target);
|
|
1222
|
+
} else {
|
|
1223
|
+
throw new UsageError(`Unsupported verify action "${action}". Expected framework-change, owner, request, or browser.`);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (cli.args.json === true) {
|
|
1227
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1228
|
+
} else {
|
|
1229
|
+
console.log(renderHuman(result));
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (!result.result.ok) {
|
|
1233
|
+
process.exitCode = 1;
|
|
1234
|
+
}
|
|
1235
|
+
};
|