Skip to content

Commit 2223e33

Browse files
committed
feat(FR-2877): implement webui-smoke-cli runner with tag filter and external endpoint
1 parent 279ab29 commit 2223e33

7 files changed

Lines changed: 690 additions & 40 deletions

File tree

packages/backend.ai-webui-smoke-cli/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,15 @@ Post-install smoke verification CLI for Backend.AI WebUI. See
44
[`.specs/FR-2871-webui-smoke-cli/spec.md`](../../.specs/FR-2871-webui-smoke-cli/spec.md)
55
for the full spec. The operator-facing README (EN + KO) lands in FR-2883
66
(Phase 2).
7+
8+
## Usage (alpha)
9+
10+
```sh
11+
bai-smoke run \
12+
--endpoint https://webui.example.com \
13+
--email admin@example.com \
14+
--password "***" \
15+
--output ./smoke-report
16+
```
17+
18+
This is an alpha MVP. Full operator docs land with FR-2883.

packages/backend.ai-webui-smoke-cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"files": [
2020
"bin",
2121
"dist",
22+
"playwright.smoke.config.ts",
2223
"README.md"
2324
],
2425
"scripts": {
@@ -30,6 +31,7 @@
3031
"version:print": "node ./bin/bai-smoke.cjs version"
3132
},
3233
"dependencies": {
34+
"@playwright/test": "^1.58.2",
3335
"commander": "^12.1.0"
3436
},
3537
"devDependencies": {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
/**
6+
* Playwright configuration for the `bai-smoke` runner.
7+
*
8+
* This config is invoked by `bai-smoke run` (`src/runner.ts`) via
9+
* `npx playwright test --config <this-file>`. The runner is the single
10+
* place that translates CLI arguments into the env vars consumed here —
11+
* we deliberately do NOT load `e2e/envs/.env.playwright` from this file
12+
* because the smoke CLI runs against arbitrary customer endpoints, not
13+
* the dev fixtures the e2e suite assumes.
14+
*
15+
* Env contract (set by the runner):
16+
* BAI_SMOKE_REPORT_DIR — output directory for html / json reports
17+
* BAI_SMOKE_WORKERS — playwright worker count (optional)
18+
* BAI_SMOKE_TIMEOUT_MS — per-test timeout in ms (default 180000)
19+
* BAI_SMOKE_GREP — grep regex source (no slashes), e.g. `@smoke(-any|-admin)?\b`
20+
* BAI_SMOKE_GREP_INVERT — grepInvert regex source (optional)
21+
* BAI_SMOKE_PAGES — comma-separated page directory names (optional)
22+
* BAI_SMOKE_HEADED — '1' to launch a headed browser
23+
*
24+
* Test credentials (E2E_*) are set by the runner as well — see
25+
* `src/config.ts#buildPlaywrightEnv`.
26+
*/
27+
28+
// Resolve repo root e2e/ directory relative to this config file.
29+
// File layout: <repo>/packages/backend.ai-webui-smoke-cli/playwright.smoke.config.ts
30+
const __filename = fileURLToPath(import.meta.url);
31+
const __dirname = path.dirname(__filename);
32+
const E2E_DIR = path.resolve(__dirname, '..', '..', 'e2e');
33+
34+
const reportDir = process.env.BAI_SMOKE_REPORT_DIR ?? path.resolve(process.cwd(), 'smoke-report');
35+
36+
const workersEnv = process.env.BAI_SMOKE_WORKERS;
37+
const workers = workersEnv ? Number.parseInt(workersEnv, 10) : undefined;
38+
39+
const timeoutEnv = process.env.BAI_SMOKE_TIMEOUT_MS;
40+
const timeout = timeoutEnv ? Number.parseInt(timeoutEnv, 10) : 180000;
41+
42+
const grepSource = process.env.BAI_SMOKE_GREP;
43+
const grepInvertSource = process.env.BAI_SMOKE_GREP_INVERT;
44+
45+
// Pages filter — match by directory under e2e/. e.g. BAI_SMOKE_PAGES="session,vfolder"
46+
// turns into testMatch ['**/session/**', '**/vfolder/**'].
47+
const pagesEnv = process.env.BAI_SMOKE_PAGES;
48+
const pages = pagesEnv
49+
? pagesEnv
50+
.split(',')
51+
.map((p) => p.trim())
52+
.filter(Boolean)
53+
: undefined;
54+
const testMatch = pages && pages.length > 0 ? pages.map((p) => `**/${p}/**`) : undefined;
55+
56+
const headed = process.env.BAI_SMOKE_HEADED === '1';
57+
58+
export default defineConfig({
59+
testDir: E2E_DIR,
60+
testMatch,
61+
fullyParallel: true,
62+
forbidOnly: !!process.env.CI,
63+
retries: 0,
64+
workers: workers && Number.isFinite(workers) && workers > 0 ? workers : undefined,
65+
timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : 180000,
66+
grep: grepSource ? new RegExp(grepSource) : undefined,
67+
grepInvert: grepInvertSource ? new RegExp(grepInvertSource) : undefined,
68+
reporter: [
69+
['html', { outputFolder: path.join(reportDir, 'html'), open: 'never' }],
70+
['json', { outputFile: path.join(reportDir, 'results.json') }],
71+
['list'],
72+
],
73+
snapshotPathTemplate: `${E2E_DIR}/{testFileDir}/snapshot/{arg}{ext}`,
74+
use: {
75+
trace: 'retain-on-failure',
76+
video: 'retain-on-failure',
77+
headless: !headed,
78+
ignoreHTTPSErrors: true,
79+
},
80+
projects: [
81+
{
82+
name: 'chromium',
83+
use: { ...devices['Desktop Chrome'], locale: 'en-US' },
84+
},
85+
],
86+
});

packages/backend.ai-webui-smoke-cli/src/cli.ts

Lines changed: 144 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
/**
22
* Entrypoint for the `bai-smoke` CLI.
33
*
4-
* MVP scaffold (FR-2876):
5-
* - `list` — print the catalog of smoke categories. Functional.
6-
* - `version` — print CLI version + bundled SHA + Playwright version. Functional.
7-
* - `run` — stub. The actual Playwright runner ships in FR-2877.
4+
* Subcommands (FR-2877 MVP):
5+
* - `list` — print the catalog of smoke categories.
6+
* - `version` — print CLI + WebUI + Playwright version info.
7+
* - `run` — execute the smoke suite against a customer endpoint.
88
*
9-
* Global option parsing (`--endpoint`, `--email`, etc.) is declared on `run`
10-
* so `--help` documents the eventual flag surface, but every flag is a no-op
11-
* at this stage.
9+
* Phase 2 subcommands (`doctor`, `preflight`) ship under FR-2878+.
1210
*/
13-
import { Command } from 'commander';
11+
import { Command, Option } from 'commander';
12+
import path from 'node:path';
1413

1514
import { SMOKE_CATALOG } from './catalog.js';
15+
import {
16+
parseDuration,
17+
splitCsvArg,
18+
type SmokeRoleSelection,
19+
type SmokeRunOptions,
20+
} from './config.js';
21+
import { runSmoke } from './runner.js';
1622
import {
1723
CLI_NAME,
1824
CLI_VERSION,
@@ -61,39 +67,147 @@ program
6167
process.stdout.write(` platform : ${process.platform}-${process.arch}\n`);
6268
});
6369

64-
// `run` is a stub until FR-2877 wires the Playwright runner.
6570
program
6671
.command('run')
67-
.description(
68-
'Run the smoke suite against an endpoint. Not yet implemented — coming in FR-2877.',
72+
.description('Run the smoke suite against a Backend.AI WebUI endpoint.')
73+
.addOption(
74+
new Option('--endpoint <url>', 'Backend.AI WebUI endpoint URL.')
75+
.env('BAI_SMOKE_ENDPOINT')
76+
.makeOptionMandatory(true),
77+
)
78+
.option(
79+
'--webserver <url>',
80+
'Backend.AI webserver endpoint URL. Defaults to --endpoint when omitted.',
81+
)
82+
.addOption(
83+
new Option('--email <email>', 'Login email or username.')
84+
.env('BAI_SMOKE_EMAIL')
85+
.makeOptionMandatory(true),
86+
)
87+
.addOption(
88+
new Option(
89+
'--password <password>',
90+
'Login password. Prefer --password-stdin or BAI_SMOKE_PASSWORD env.',
91+
).env('BAI_SMOKE_PASSWORD'),
92+
)
93+
.option('--password-stdin', 'Read the password from stdin instead of --password.')
94+
.addOption(
95+
new Option('--role <role>', 'Role selection: auto, admin, or user.')
96+
.choices(['auto', 'admin', 'user'])
97+
.default('auto'),
6998
)
70-
.option('--endpoint <url>', 'Backend.AI webui endpoint URL.')
71-
.option('--webserver <url>', 'Backend.AI webserver endpoint URL.')
72-
.option('--email <email>', 'Account email or username.')
73-
.option('--password <password>', 'Account password (prefer --password-stdin).')
74-
.option('--password-stdin', 'Read the password from stdin.')
99+
.option('--include <tags>', 'Comma-separated extra tags to include (e.g. "@critical").')
100+
.option('--exclude <tags>', 'Comma-separated tags to exclude.')
75101
.option(
76-
'--role <role>',
77-
'Force role selection: auto | admin | user | monitor.',
78-
'auto',
102+
'--pages <names>',
103+
'Comma-separated page directory names (e.g. "session,vfolder").',
104+
)
105+
.option('--workers <n>', 'Playwright worker count.', (v) => Number.parseInt(v, 10))
106+
.option(
107+
'--timeout <duration>',
108+
'Per-test timeout. Accepts "180s", "3m", or raw ms.',
109+
'180s',
110+
)
111+
.option(
112+
'--output <dir>',
113+
'Output directory for the smoke report.',
114+
() => defaultOutputDir(),
115+
defaultOutputDir(),
79116
)
80-
.option('--include <tags...>', 'Additional tags to include in the run.')
81-
.option('--exclude <tags...>', 'Tags to exclude from the run.')
82-
.option('--pages <pages...>', 'Restrict the run to specific page categories.')
83-
.option('--workers <n>', 'Playwright worker count.', '1')
84-
.option('--timeout <ms>', 'Per-test timeout in milliseconds.', '120000')
85-
.option('--output <dir>', 'Output directory for the smoke report.', './smoke-report')
86117
.option('--headed', 'Run the browser in headed mode (debugging only).', false)
87118
.option('--insecure-tls', 'Accept self-signed TLS certificates.', false)
88-
.action(() => {
89-
process.stderr.write(
90-
'bai-smoke run: Not yet implemented. Coming in FR-2877.\n',
91-
);
92-
process.exit(2);
119+
.action(async (raw: Record<string, unknown>) => {
120+
const password = await resolvePassword(raw);
121+
if (!password) {
122+
process.stderr.write(
123+
'bai-smoke run: --password, --password-stdin, or BAI_SMOKE_PASSWORD is required.\n',
124+
);
125+
process.exit(2);
126+
}
127+
128+
const endpoint = String(raw.endpoint);
129+
const webserver =
130+
typeof raw.webserver === 'string' && raw.webserver.length > 0
131+
? raw.webserver
132+
: endpoint;
133+
if (webserver === endpoint && !raw.webserver) {
134+
process.stderr.write(
135+
'[bai-smoke] --webserver not supplied; reusing --endpoint as the webserver URL.\n',
136+
);
137+
}
138+
139+
let timeoutMs: number;
140+
try {
141+
timeoutMs = parseDuration(String(raw.timeout ?? '180s'));
142+
} catch (err) {
143+
process.stderr.write(`bai-smoke run: ${(err as Error).message}\n`);
144+
process.exit(2);
145+
return;
146+
}
147+
148+
const opts: SmokeRunOptions = {
149+
endpoint,
150+
webserver,
151+
email: String(raw.email),
152+
password,
153+
role: (raw.role as SmokeRoleSelection) ?? 'auto',
154+
include: splitCsvArg(raw.include as string | string[] | undefined),
155+
exclude: splitCsvArg(raw.exclude as string | string[] | undefined),
156+
pages: splitCsvArg(raw.pages as string | string[] | undefined),
157+
workers: typeof raw.workers === 'number' && raw.workers > 0 ? raw.workers : undefined,
158+
timeoutMs,
159+
outputDir: path.resolve(String(raw.output ?? defaultOutputDir())),
160+
headed: raw.headed === true,
161+
insecureTls: raw.insecureTls === true,
162+
};
163+
164+
const { exitCode, reportPath, summary } = await runSmoke(opts);
165+
166+
process.stdout.write('\n');
167+
process.stdout.write('bai-smoke summary\n');
168+
process.stdout.write(`${'-'.repeat(60)}\n`);
169+
process.stdout.write(` endpoint : ${summary.endpoint}\n`);
170+
process.stdout.write(` webserver : ${summary.webserver}\n`);
171+
process.stdout.write(` role : ${summary.role} (selection: ${summary.roleSelection})\n`);
172+
if (summary.results) {
173+
const { total, passed, failed, skipped, flaky } = summary.results;
174+
process.stdout.write(
175+
` results : ${passed} passed, ${failed} failed, ${skipped} skipped, ${flaky} flaky (total ${total})\n`,
176+
);
177+
} else {
178+
process.stdout.write(' results : (no JSON reporter output found)\n');
179+
}
180+
process.stdout.write(` report : ${reportPath}\n`);
181+
process.stdout.write(` summary : ${path.join(opts.outputDir, 'summary.json')}\n`);
182+
process.exit(exitCode);
93183
});
94184

95185
program.parseAsync(process.argv).catch((err: unknown) => {
96186
// eslint-disable-next-line no-console
97187
console.error(err);
98188
process.exit(1);
99189
});
190+
191+
function defaultOutputDir(): string {
192+
const iso = new Date().toISOString().replace(/[:.]/g, '-');
193+
return path.resolve(process.cwd(), `smoke-report-${iso}`);
194+
}
195+
196+
async function resolvePassword(raw: Record<string, unknown>): Promise<string | undefined> {
197+
if (typeof raw.password === 'string' && raw.password.length > 0) {
198+
return raw.password;
199+
}
200+
if (raw.passwordStdin === true) {
201+
return readStdin();
202+
}
203+
return undefined;
204+
}
205+
206+
function readStdin(): Promise<string> {
207+
return new Promise((resolve, reject) => {
208+
const chunks: Buffer[] = [];
209+
process.stdin.on('data', (c: Buffer) => chunks.push(c));
210+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').trim()));
211+
process.stdin.on('error', reject);
212+
});
213+
}

0 commit comments

Comments
 (0)