proteum 2.2.2 → 2.2.6

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.
@@ -0,0 +1,204 @@
1
+ import { spawn } from 'child_process';
2
+ import dotenv from 'dotenv';
3
+ import fs from 'fs-extra';
4
+ import got from 'got';
5
+ import path from 'path';
6
+ import { UsageError } from 'clipanion';
7
+
8
+ import cli from '..';
9
+ import type { TDevSessionErrorResponse, TDevSessionStartResponse } from '../../common/dev/session';
10
+
11
+ type TPlaywrightInvocation = {
12
+ command: string;
13
+ args: string[];
14
+ };
15
+
16
+ const normalizeBaseUrl = (value: string) => value.replace(/\/+$/, '');
17
+
18
+ const getRouterPortFromManifest = () => {
19
+ const manifestFilepath = path.join(cli.args.workdir as string, '.proteum', 'manifest.json');
20
+ if (!fs.existsSync(manifestFilepath)) return undefined;
21
+
22
+ const manifest = fs.readJsonSync(manifestFilepath, { throws: false }) as
23
+ | { env?: { resolved?: { routerPort?: number } } }
24
+ | undefined;
25
+ const port = manifest?.env?.resolved?.routerPort;
26
+
27
+ if (typeof port !== 'number' || port <= 0) return undefined;
28
+
29
+ return String(port);
30
+ };
31
+
32
+ const getRouterPort = () => {
33
+ const overridePort = typeof cli.args.port === 'string' && cli.args.port ? cli.args.port : '';
34
+ if (overridePort) return overridePort;
35
+
36
+ const manifestPort = getRouterPortFromManifest();
37
+ if (manifestPort) return manifestPort;
38
+
39
+ return '';
40
+ };
41
+
42
+ const getBaseUrlCandidates = () => {
43
+ const explicitUrl = typeof cli.args.url === 'string' && cli.args.url ? cli.args.url.trim() : '';
44
+ if (explicitUrl) return [normalizeBaseUrl(explicitUrl)];
45
+
46
+ const port = getRouterPort();
47
+ if (!port) return [];
48
+
49
+ return [...new Set([`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://[::1]:${port}`])];
50
+ };
51
+
52
+ const getSessionErrorMessage = (body: TDevSessionErrorResponse | object | string | undefined, statusCode: number) => {
53
+ if (typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string') {
54
+ return body.error;
55
+ }
56
+
57
+ return `Session request failed with status ${statusCode}.`;
58
+ };
59
+
60
+ const hasStructuredSessionError = (body: TDevSessionErrorResponse | object | string | undefined): body is TDevSessionErrorResponse =>
61
+ typeof body === 'object' && body !== null && 'error' in body && typeof body.error === 'string';
62
+
63
+ const requestSession = async ({ email, role }: { email: string; role: string }) => {
64
+ const attempts: string[] = [];
65
+
66
+ for (const baseUrl of getBaseUrlCandidates()) {
67
+ try {
68
+ const response = await got(`${baseUrl}/__proteum/session/start`, {
69
+ method: 'POST',
70
+ json: role ? { email, role } : { email },
71
+ responseType: 'json',
72
+ throwHttpErrors: false,
73
+ retry: { limit: 0 },
74
+ });
75
+
76
+ if (response.statusCode >= 400) {
77
+ if (response.statusCode === 404 && !hasStructuredSessionError(response.body as TDevSessionErrorResponse | object | string | undefined)) {
78
+ attempts.push(`${baseUrl}/__proteum/session/start: returned 404`);
79
+ continue;
80
+ }
81
+
82
+ throw new UsageError(
83
+ getSessionErrorMessage(response.body as TDevSessionErrorResponse | object | string | undefined, response.statusCode),
84
+ );
85
+ }
86
+
87
+ return {
88
+ baseUrl,
89
+ token: (response.body as TDevSessionStartResponse).session.token,
90
+ };
91
+ } catch (error) {
92
+ if (error instanceof UsageError) throw error;
93
+
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ attempts.push(`${baseUrl}/__proteum/session/start: ${message}`);
96
+ }
97
+ }
98
+
99
+ throw new UsageError(
100
+ [
101
+ 'Could not reach the Proteum session server.',
102
+ ...attempts.map((attempt) => `- ${attempt}`),
103
+ 'Start the app with `proteum dev`, then pass --port or --url to `proteum e2e`.',
104
+ ].join('\n'),
105
+ );
106
+ };
107
+
108
+ const resolveBaseUrl = (sessionBaseUrl?: string) => {
109
+ if (sessionBaseUrl) return sessionBaseUrl;
110
+
111
+ const [baseUrl] = getBaseUrlCandidates();
112
+ if (baseUrl) return baseUrl;
113
+
114
+ throw new UsageError('Could not determine E2E_BASE_URL. Pass --port or --url to `proteum e2e`.');
115
+ };
116
+
117
+ const parseEnvPair = (value: string) => {
118
+ const separatorIndex = value.indexOf('=');
119
+ if (separatorIndex <= 0) {
120
+ throw new UsageError(`Invalid --env value "${value}". Expected KEY=value.`);
121
+ }
122
+
123
+ const key = value.slice(0, separatorIndex).trim();
124
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
125
+ throw new UsageError(`Invalid --env key "${key}".`);
126
+ }
127
+
128
+ return { key, value: value.slice(separatorIndex + 1) };
129
+ };
130
+
131
+ const readEnvFiles = (filepaths: string[]) => {
132
+ const env: Record<string, string> = {};
133
+
134
+ for (const filepath of filepaths) {
135
+ const absoluteFilepath = path.resolve(cli.args.workdir as string, filepath);
136
+
137
+ if (!fs.existsSync(absoluteFilepath)) {
138
+ throw new UsageError(`Env file does not exist: ${absoluteFilepath}`);
139
+ }
140
+
141
+ Object.assign(env, dotenv.parse(fs.readFileSync(absoluteFilepath)));
142
+ }
143
+
144
+ return env;
145
+ };
146
+
147
+ const resolvePlaywrightInvocation = (appRoot: string): TPlaywrightInvocation => {
148
+ const binaryName = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
149
+ const localBinary = path.join(appRoot, 'node_modules', '.bin', binaryName);
150
+
151
+ if (fs.existsSync(localBinary)) {
152
+ return { command: localBinary, args: ['test'] };
153
+ }
154
+
155
+ return { command: 'npx', args: ['playwright', 'test'] };
156
+ };
157
+
158
+ const runPlaywright = async ({ env, playwrightArgs }: { env: Record<string, string>; playwrightArgs: string[] }) => {
159
+ const appRoot = cli.args.workdir as string;
160
+ const invocation = resolvePlaywrightInvocation(appRoot);
161
+
162
+ return await new Promise<number | null>((resolve, reject) => {
163
+ const child = spawn(invocation.command, [...invocation.args, ...playwrightArgs], {
164
+ cwd: appRoot,
165
+ env: {
166
+ ...process.env,
167
+ ...env,
168
+ },
169
+ stdio: 'inherit',
170
+ });
171
+
172
+ child.once('error', reject);
173
+ child.once('close', (exitCode) => resolve(exitCode));
174
+ });
175
+ };
176
+
177
+ export const run = async () => {
178
+ const sessionEmail = typeof cli.args.sessionEmail === 'string' ? cli.args.sessionEmail.trim() : '';
179
+ const sessionRole = typeof cli.args.sessionRole === 'string' ? cli.args.sessionRole.trim() : '';
180
+ const envFilepaths = Array.isArray(cli.args.envFile) ? cli.args.envFile : [];
181
+ const envPairs = Array.isArray(cli.args.env) ? cli.args.env : [];
182
+ const playwrightArgs = Array.isArray(cli.args.playwrightArgs) ? cli.args.playwrightArgs : [];
183
+ const explicitPort = getRouterPort();
184
+
185
+ const explicitEnv = readEnvFiles(envFilepaths);
186
+ for (const pair of envPairs) {
187
+ const parsed = parseEnvPair(pair);
188
+ explicitEnv[parsed.key] = parsed.value;
189
+ }
190
+
191
+ const session = sessionEmail ? await requestSession({ email: sessionEmail, role: sessionRole }) : undefined;
192
+ const baseUrl = resolveBaseUrl(session?.baseUrl);
193
+ const exitCode = await runPlaywright({
194
+ env: {
195
+ ...explicitEnv,
196
+ E2E_BASE_URL: baseUrl,
197
+ ...(explicitPort ? { E2E_PORT: explicitPort } : {}),
198
+ ...(session?.token ? { E2E_AUTH_TOKEN: session.token } : {}),
199
+ },
200
+ playwrightArgs,
201
+ });
202
+
203
+ return exitCode ?? 1;
204
+ };
@@ -1,5 +1,5 @@
1
1
  import cli from '..';
2
- import { refreshGeneratedTypings, runAppTypecheck } from '../utils/check';
2
+ import { hasAppConfig, refreshGeneratedTypings, runAppTypecheck } from '../utils/check';
3
3
  import { renderRows } from '../presentation/layout';
4
4
  import { renderStep, renderSuccess, renderTitle } from '../presentation/ink';
5
5
 
@@ -23,8 +23,12 @@ export const run = async (): Promise<void> => {
23
23
  renderRows([{ label: 'app', value: cli.paths.appRoot === process.cwd() ? '.' : cli.paths.appRoot }]),
24
24
  ].join('\n\n'),
25
25
  );
26
- console.info(await renderStep('[1/2]', 'Refreshing generated typings.'));
27
- await refreshGeneratedTypings();
26
+ if (hasAppConfig()) {
27
+ console.info(await renderStep('[1/2]', 'Refreshing generated typings.'));
28
+ await refreshGeneratedTypings();
29
+ } else {
30
+ console.info(await renderStep('[1/2]', 'Skipping generated typings: no Proteum app config found.'));
31
+ }
28
32
  console.info(await renderStep('[2/2]', 'Running TypeScript typechecking.'));
29
33
  await runAppTypecheck();
30
34
  console.info(await renderSuccess('Typecheck passed.'));
@@ -14,6 +14,7 @@ export const proteumCommandNames = [
14
14
  'typecheck',
15
15
  'lint',
16
16
  'check',
17
+ 'e2e',
17
18
  'connect',
18
19
  'doctor',
19
20
  'explain',
@@ -55,7 +56,7 @@ export const proteumRecommendedFlow: TRow[] = [
55
56
 
56
57
  export const proteumCommandGroups: Array<{ title: string; names: TProteumCommandName[] }> = [
57
58
  { title: 'Daily workflow', names: ['dev', 'refresh', 'build'] },
58
- { title: 'Quality gates', names: ['typecheck', 'lint', 'check'] },
59
+ { title: 'Quality gates', names: ['typecheck', 'lint', 'check', 'e2e'] },
59
60
  { title: 'Manifest and contracts', names: ['connect', 'doctor', 'explain', 'orient', 'diagnose', 'perf', 'trace', 'command', 'session', 'verify'] },
60
61
  { title: 'Project scaffolding', names: ['init', 'configure', 'create', 'migrate'] },
61
62
  ];
@@ -112,22 +113,22 @@ export const proteumCommands: Record<TProteumCommandName, TProteumCommandDoc> =
112
113
  configure: {
113
114
  name: 'configure',
114
115
  category: 'Project scaffolding',
115
- summary: 'Interactively configure Proteum-managed instruction symlinks for a standalone app or monorepo app root.',
116
+ summary: 'Interactively configure Proteum-managed instruction stubs for a standalone app or monorepo app root.',
116
117
  usage: 'proteum configure agents',
117
118
  bestFor:
118
- 'Creating or switching the managed `AGENTS.md` instruction layout intentionally instead of having `init` or `dev` write symlinks implicitly.',
119
+ 'Creating or switching the managed `AGENTS.md` instruction layout intentionally instead of having `init` or `dev` write instruction files implicitly.',
119
120
  examples: [
120
121
  {
121
- description: 'Configure instruction symlinks for the current standalone app',
122
+ description: 'Configure instruction stubs for the current standalone app',
122
123
  command: 'proteum configure agents',
123
124
  },
124
125
  ],
125
126
  notes: [
126
- 'This command is interactive. It asks whether the current Proteum app belongs to a monorepo and, if so, which ancestor path should receive the reusable root `AGENTS.md` symlink.',
127
+ 'This command is interactive. It asks whether the current Proteum app belongs to a monorepo and, if so, which ancestor path should receive the reusable root `AGENTS.md` stub.',
127
128
  'Standalone mode writes the full app-root instruction set into the current Proteum app root.',
128
129
  'Monorepo mode writes the reusable root `AGENTS.md` into the chosen monorepo root and switches the current app root `AGENTS.md` to the app-root addendum.',
129
- 'If a target path already contains a non-managed file or foreign symlink, the interactive flow asks whether to overwrite it with the Proteum-managed symlink.',
130
- 'Declined non-managed paths are left untouched; Proteum still creates missing symlinks and updates symlinks it already manages.',
130
+ 'If a target path already contains a non-managed file or foreign symlink, the interactive flow asks whether to overwrite it with the Proteum-managed stub.',
131
+ 'Declined non-managed paths are left untouched; Proteum still creates missing stubs and updates stubs or symlinks it already manages.',
131
132
  ],
132
133
  status: 'experimental',
133
134
  },
@@ -276,6 +277,35 @@ export const proteumCommands: Record<TProteumCommandName, TProteumCommandDoc> =
276
277
  notes: ['This command executes refresh, typecheck, then lint in that order.'],
277
278
  status: 'stable',
278
279
  },
280
+ e2e: {
281
+ name: 'e2e',
282
+ category: 'Quality gates',
283
+ summary: 'Run app Playwright tests with Proteum-managed E2E environment values.',
284
+ usage:
285
+ 'proteum e2e [--cwd <path>] [--port <port>|--url <url>] [--session-email <email>] [--session-role <role>] [--env KEY=value] [--env-file <path>] [--grep <text>] [--project <name>] [specs...]',
286
+ bestFor:
287
+ 'Running targeted or full Playwright suites without shell-leading environment assignments for base URLs, auth tokens, or per-run values.',
288
+ examples: [
289
+ {
290
+ description: 'Run the full suite against a local dev server',
291
+ command: 'proteum e2e --port 3101',
292
+ },
293
+ {
294
+ description: 'Run one spec with a dev auth token minted internally',
295
+ command: 'proteum e2e --port 3101 --session-email admin@example.com --session-role ADMIN tests/e2e/features/admin.spec.ts',
296
+ },
297
+ {
298
+ description: 'Load extra dotenv values before Playwright starts',
299
+ command: 'proteum e2e --url http://localhost:3101 --env-file .proteum/e2e.env --grep smoke',
300
+ },
301
+ ],
302
+ notes: [
303
+ '`proteum e2e` spawns Playwright with `E2E_BASE_URL`, optional `E2E_PORT`, optional `E2E_AUTH_TOKEN`, and any `--env`/`--env-file` values in the child process environment.',
304
+ 'Common Playwright flags are exposed directly: `--config`, `--debug`, `--grep`, `--headed`, `--list`, `--project`, `--reporter`, `--retries`, `--timeout`, `--ui`, and `--workers`.',
305
+ 'The shell command itself stays `proteum e2e ...`, so Codex does not need to run `FOO=bar npx playwright test ...`.',
306
+ ],
307
+ status: 'experimental',
308
+ },
279
309
  connect: {
280
310
  name: 'connect',
281
311
  category: 'Manifest and contracts',
@@ -4,11 +4,11 @@ import cli, { type TArgsObject } from '../context';
4
4
  import { createClipanionUsage, proteumCommands, type TProteumCommandName } from '../presentation/commands';
5
5
  import { createArgs } from './argv';
6
6
 
7
- type TRunModule = { run: () => Promise<void> };
7
+ type TRunModule = { run: () => Promise<number | void> };
8
8
 
9
9
  export const runCommandModule = async (loader: () => Promise<TRunModule>) => {
10
10
  const module = await loader();
11
- await module.run();
11
+ return await module.run();
12
12
  };
13
13
 
14
14
  export abstract class ProteumCommand extends Command {
@@ -259,6 +259,63 @@ class CheckCommand extends ProteumCommand {
259
259
  }
260
260
  }
261
261
 
262
+ class E2eCommand extends ProteumCommand {
263
+ public static paths = [['e2e']];
264
+
265
+ public static usage = buildUsage('e2e');
266
+
267
+ public cwd = Option.String('--cwd', { description: 'Run Playwright against another Proteum app root.' });
268
+ public port = Option.String('--port', { description: 'Set E2E_BASE_URL from a local router port.' });
269
+ public url = Option.String('--url', { description: 'Set E2E_BASE_URL from an explicit base URL.' });
270
+ public sessionEmail = Option.String('--session-email', {
271
+ description: 'Mint a dev session before Playwright starts and pass it as E2E_AUTH_TOKEN.',
272
+ });
273
+ public sessionRole = Option.String('--session-role', { description: 'Require the dev session user to have this role.' });
274
+ public env = Option.Array('--env', [], { description: 'Pass an environment value to Playwright as KEY=value.' });
275
+ public envFile = Option.Array('--env-file', [], { description: 'Load environment values from a dotenv file before Playwright starts.' });
276
+ public config = Option.String('--config', { description: 'Playwright config file.' });
277
+ public debug = Option.Boolean('--debug', false, { description: 'Run Playwright in debug mode.' });
278
+ public grep = Option.String('--grep', { description: 'Playwright grep filter.' });
279
+ public headed = Option.Boolean('--headed', false, { description: 'Run browsers in headed mode.' });
280
+ public list = Option.Boolean('--list', false, { description: 'List Playwright tests without running them.' });
281
+ public project = Option.Array('--project', [], { description: 'Playwright project name. Can be repeated.' });
282
+ public reporter = Option.String('--reporter', { description: 'Playwright reporter.' });
283
+ public retries = Option.String('--retries', { description: 'Playwright retry count.' });
284
+ public timeout = Option.String('--timeout', { description: 'Playwright per-test timeout.' });
285
+ public ui = Option.Boolean('--ui', false, { description: 'Run Playwright in UI mode.' });
286
+ public workers = Option.String('--workers', { description: 'Playwright worker count.' });
287
+ public specs = Option.Rest();
288
+
289
+ public async execute() {
290
+ const playwrightArgs = [
291
+ ...(this.config ? ['--config', this.config] : []),
292
+ ...(this.debug ? ['--debug'] : []),
293
+ ...(this.grep ? ['--grep', this.grep] : []),
294
+ ...(this.headed ? ['--headed'] : []),
295
+ ...(this.list ? ['--list'] : []),
296
+ ...this.project.flatMap((project) => ['--project', project]),
297
+ ...(this.reporter ? ['--reporter', this.reporter] : []),
298
+ ...(this.retries ? ['--retries', this.retries] : []),
299
+ ...(this.timeout ? ['--timeout', this.timeout] : []),
300
+ ...(this.ui ? ['--ui'] : []),
301
+ ...(this.workers ? ['--workers', this.workers] : []),
302
+ ...this.specs,
303
+ ];
304
+
305
+ this.setCliArgs({
306
+ env: this.env,
307
+ envFile: this.envFile,
308
+ playwrightArgs,
309
+ port: this.port ?? '',
310
+ sessionEmail: this.sessionEmail ?? '',
311
+ sessionRole: this.sessionRole ?? '',
312
+ url: this.url ?? '',
313
+ workdir: this.cwd ?? '',
314
+ });
315
+ return await runCommandModule(() => import('../commands/e2e'));
316
+ }
317
+ }
318
+
262
319
  class ConnectCommand extends ProteumCommand {
263
320
  public static paths = [['connect']];
264
321
 
@@ -608,6 +665,7 @@ export const registeredCommands = {
608
665
  typecheck: TypecheckCommand,
609
666
  lint: LintCommand,
610
667
  check: CheckCommand,
668
+ e2e: E2eCommand,
611
669
  connect: ConnectCommand,
612
670
  doctor: DoctorCommand,
613
671
  explain: ExplainCommand,
@@ -640,6 +698,7 @@ export const createCli = (version: string) => {
640
698
  clipanion.register(TypecheckCommand);
641
699
  clipanion.register(LintCommand);
642
700
  clipanion.register(CheckCommand);
701
+ clipanion.register(E2eCommand);
643
702
  clipanion.register(ConnectCommand);
644
703
  clipanion.register(DoctorCommand);
645
704
  clipanion.register(ExplainCommand);
@@ -727,7 +727,7 @@ export const runInitScaffold = async () => {
727
727
  ? 'Run `npm run dev` in the new app directory.'
728
728
  : 'Run `npm install`, then `npm run dev` in the new app directory.',
729
729
  );
730
- result.nextSteps.push('Run `proteum configure agents` when you want Proteum-managed instruction symlinks.');
730
+ result.nextSteps.push('Run `proteum configure agents` when you want Proteum-managed instruction stubs.');
731
731
  result.nextSteps.push('Use `proteum create page|controller|command|route|service ...` to add app artifacts.');
732
732
 
733
733
  printResult(result, createInitSummary(result, config));