ykq-playwright-ci 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -186,10 +186,13 @@ projects.config.json
186
186
  失败时会生成:
187
187
 
188
188
  ```text
189
+ test-results/summary.md
189
190
  test-results/
190
191
  playwright-report/
191
192
  ```
192
193
 
194
+ 其中 `test-results/summary.md` 是 Markdown 摘要,适合直接发给别人看。
195
+
193
196
  在源码仓库里可以打开报告:
194
197
 
195
198
  ```bash
@@ -268,6 +271,7 @@ npm run typecheck
268
271
  | `PW-TEST-002` | 未登录访问 `/home` 是否跳回登录页 |
269
272
  | `PW-TEST-003` | `/serviceAgreement` 是否允许未登录访问 |
270
273
  | `PW-TEST-004` | 登录表单基础校验 |
274
+ | `PW-TEST-005` | 使用测试账号登录成功 |
271
275
 
272
276
  ## 安全注意
273
277
 
@@ -1,11 +1,25 @@
1
+ /* global process */
2
+
1
3
  import { test, expect } from '../../../src/fixtures/base.fixture';
2
4
  import { annotateCase } from '../../../src/utils/evidence';
3
5
  import { appEnv, doctorURL, hasDoctorBaseURL } from '../../../src/utils/env';
4
6
 
7
+ const doctorUsername = process.env.DOCTOR_USERNAME?.trim() || '';
8
+ const doctorLoginCode = (
9
+ process.env.DOCTOR_LOGIN_CODE ||
10
+ process.env.DOCTOR_OTP ||
11
+ process.env.DOCTOR_PASSWORD ||
12
+ ''
13
+ ).trim();
14
+
5
15
  async function gotoDoctor(path: string, page: import('@playwright/test').Page): Promise<void> {
6
16
  await page.goto(doctorURL(path), { waitUntil: 'domcontentloaded' });
7
17
  }
8
18
 
19
+ function hasDoctorLoginCredentials(): boolean {
20
+ return Boolean(doctorUsername && doctorLoginCode);
21
+ }
22
+
9
23
  test.describe('doctor-master test environment basics', () => {
10
24
  test.beforeEach(async () => {
11
25
  test.skip(!hasDoctorBaseURL(), 'DOCTOR_BASE_URL is required for doctor-master test environment tests.');
@@ -86,4 +100,40 @@ test.describe('doctor-master test environment basics', () => {
86
100
  await page.locator('.submit-btn', { hasText: '登录' }).click();
87
101
  await expect(page.getByText('请阅读并勾选《荷叶健康用户服务协议》')).toBeVisible();
88
102
  });
103
+
104
+ test('PW-TEST-005 @smoke doctor can login with configured test account', async ({
105
+ page
106
+ }, testInfo) => {
107
+ test.skip(
108
+ !hasDoctorLoginCredentials(),
109
+ 'DOCTOR_USERNAME and DOCTOR_LOGIN_CODE/DOCTOR_OTP/DOCTOR_PASSWORD are required.'
110
+ );
111
+ annotateCase(testInfo, {
112
+ caseId: 'PW-TEST-005',
113
+ evidenceId: 'TEST-doctor-login-success',
114
+ priority: 'Smoke'
115
+ });
116
+
117
+ await gotoDoctor(appEnv.doctorLoginPath, page);
118
+
119
+ const loginResponsePromise = page.waitForResponse(
120
+ (response) =>
121
+ response.url().includes('/physician/account/loginPhysician') &&
122
+ response.request().method() === 'POST'
123
+ );
124
+
125
+ await page.getByPlaceholder('手机号').fill(doctorUsername);
126
+ await page.getByPlaceholder('图形验证码').fill('playwright');
127
+ await page.getByPlaceholder('验证码', { exact: true }).fill(doctorLoginCode);
128
+ await page.locator('.el-checkbox', { hasText: '我已阅读并同意' }).click();
129
+ await expect(page.locator('.submit-btn', { hasText: '登录' })).not.toHaveClass(/gruyBtn/);
130
+ await page.locator('.submit-btn', { hasText: '登录' }).click();
131
+
132
+ const loginResponse = await loginResponsePromise;
133
+ const loginBody = await loginResponse.json();
134
+ expect(loginResponse.ok()).toBeTruthy();
135
+ expect(loginBody?.data?.accountId, loginBody?.msg || '登录接口未返回 accountId').toBeTruthy();
136
+
137
+ await expect(page).toHaveURL(/\/physician-admin\/home(?:\?|#|$)/, { timeout: 15_000 });
138
+ });
89
139
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ykq-playwright-ci",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Company-level Playwright E2E test project for doctor PC workflows.",
5
5
  "bin": {
6
6
  "playwright-ci": "bin/playwright-ci.mjs"
@@ -16,6 +16,7 @@
16
16
  ".nvmrc",
17
17
  "eslint.config.mjs",
18
18
  "package.json",
19
+ "playwright.config.mjs",
19
20
  "playwright.config.ts",
20
21
  "projects.config.json",
21
22
  "README.md",
@@ -0,0 +1,62 @@
1
+ /* global process */
2
+
3
+ import { defineConfig, devices } from '@playwright/test';
4
+ import dotenv from 'dotenv';
5
+ import path from 'node:path';
6
+
7
+ dotenv.config();
8
+
9
+ const resultsDir = process.env.PLAYWRIGHT_CI_RESULTS_DIR || 'test-results';
10
+ const reportDir = process.env.PLAYWRIGHT_CI_REPORT_DIR || 'playwright-report';
11
+ const testDir = process.env.PLAYWRIGHT_CI_TEST_DIR || '.';
12
+
13
+ function optional(name) {
14
+ const value = process.env[name]?.trim();
15
+ return value ? value : undefined;
16
+ }
17
+
18
+ function trimTrailingSlash(value) {
19
+ return value?.replace(/\/+$/, '');
20
+ }
21
+
22
+ export default defineConfig({
23
+ testDir,
24
+ testMatch: ['**/*.spec.ts', '**/*.setup.ts'],
25
+ outputDir: resultsDir,
26
+ timeout: 60_000,
27
+ expect: {
28
+ timeout: 10_000
29
+ },
30
+ fullyParallel: false,
31
+ forbidOnly: Boolean(process.env.CI),
32
+ retries: process.env.CI ? 2 : 0,
33
+ workers: process.env.CI ? 2 : undefined,
34
+ reporter: [
35
+ ['list'],
36
+ ['html', { outputFolder: reportDir, open: 'never' }],
37
+ ['junit', { outputFile: path.join(resultsDir, 'junit.xml') }],
38
+ ['json', { outputFile: path.join(resultsDir, 'results.json') }]
39
+ ],
40
+ use: {
41
+ baseURL: trimTrailingSlash(optional('DOCTOR_BASE_URL')),
42
+ actionTimeout: 15_000,
43
+ navigationTimeout: 30_000,
44
+ trace: 'retain-on-failure',
45
+ screenshot: 'only-on-failure',
46
+ video: 'retain-on-failure'
47
+ },
48
+ projects: [
49
+ {
50
+ name: 'setup',
51
+ testMatch: /.*\.setup\.ts/
52
+ },
53
+ {
54
+ name: 'chromium',
55
+ dependencies: ['setup'],
56
+ testIgnore: /.*\.setup\.ts/,
57
+ use: {
58
+ ...devices['Desktop Chrome']
59
+ }
60
+ }
61
+ ]
62
+ });
@@ -1,10 +1,15 @@
1
1
  import { defineConfig, devices } from '@playwright/test';
2
+ import path from 'node:path';
2
3
  import { appEnv } from './src/utils/env';
3
4
 
5
+ const resultsDir = process.env.PLAYWRIGHT_CI_RESULTS_DIR || 'test-results';
6
+ const reportDir = process.env.PLAYWRIGHT_CI_REPORT_DIR || 'playwright-report';
7
+ const testDir = process.env.PLAYWRIGHT_CI_TEST_DIR || '.';
8
+
4
9
  export default defineConfig({
5
- testDir: '.',
6
- testMatch: ['cases/**/*.spec.ts', 'cases/**/*.setup.ts'],
7
- outputDir: 'test-results',
10
+ testDir,
11
+ testMatch: ['**/*.spec.ts', '**/*.setup.ts'],
12
+ outputDir: resultsDir,
8
13
  timeout: 60_000,
9
14
  expect: {
10
15
  timeout: 10_000
@@ -15,9 +20,9 @@ export default defineConfig({
15
20
  workers: process.env.CI ? 2 : undefined,
16
21
  reporter: [
17
22
  ['list'],
18
- ['html', { outputFolder: 'playwright-report', open: 'never' }],
19
- ['junit', { outputFile: 'test-results/junit.xml' }],
20
- ['json', { outputFile: 'test-results/results.json' }]
23
+ ['html', { outputFolder: reportDir, open: 'never' }],
24
+ ['junit', { outputFile: path.join(resultsDir, 'junit.xml') }],
25
+ ['json', { outputFile: path.join(resultsDir, 'results.json') }]
21
26
  ],
22
27
  use: {
23
28
  baseURL: appEnv.doctorBaseURL,
@@ -2,22 +2,41 @@
2
2
 
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
- import { createRequire } from 'node:module';
6
5
  import os from 'node:os';
7
6
  import path from 'node:path';
8
7
  import { fileURLToPath } from 'node:url';
9
8
 
10
9
  const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
- const require = createRequire(import.meta.url);
12
10
  const configPath = process.env.PROJECTS_CONFIG_PATH
13
11
  ? path.resolve(process.env.PROJECTS_CONFIG_PATH)
14
12
  : path.join(rootDir, 'projects.config.json');
15
13
  const workspaceDir = process.env.PROJECTS_CONFIG_PATH ? path.dirname(configPath) : rootDir;
16
- const playwrightConfigPath = path.join(rootDir, 'playwright.config.ts');
17
- const playwrightCliPath = path.join(path.dirname(require.resolve('@playwright/test/package.json')), 'cli.js');
14
+ const playwrightConfigPath = path.join(rootDir, 'playwright.config.mjs');
15
+ const packageNodeModulesDir = resolveNodeModulesDir(rootDir);
16
+ const playwrightCliPath = path.join(packageNodeModulesDir, '@playwright', 'test', 'cli.js');
18
17
  const knownModeArgs = new Set(['--headed', '--debug', '--ui']);
18
+ const resultsDir = process.env.PLAYWRIGHT_CI_RESULTS_DIR || path.join(workspaceDir, 'test-results');
19
+ const reportDir = process.env.PLAYWRIGHT_CI_REPORT_DIR || path.join(workspaceDir, 'playwright-report');
19
20
  let gitAskpassPath = '';
20
21
 
22
+ function resolveNodeModulesDir(startDir) {
23
+ let currentDir = startDir;
24
+
25
+ while (true) {
26
+ const nodeModulesDir = path.join(currentDir, 'node_modules');
27
+ const playwrightCli = path.join(nodeModulesDir, '@playwright', 'test', 'cli.js');
28
+ if (fs.existsSync(playwrightCli)) {
29
+ return nodeModulesDir;
30
+ }
31
+
32
+ const parentDir = path.dirname(currentDir);
33
+ if (parentDir === currentDir) {
34
+ throw new Error(`Cannot find @playwright/test from ${startDir}`);
35
+ }
36
+ currentDir = parentDir;
37
+ }
38
+ }
39
+
21
40
  function readConfig() {
22
41
  return JSON.parse(fs.readFileSync(configPath, 'utf8'));
23
42
  }
@@ -201,6 +220,125 @@ function buildPlaywrightArgs(caseDir, project, passthroughArgs) {
201
220
  return args;
202
221
  }
203
222
 
223
+ function createPlaywrightEnv(projectEnv) {
224
+ const nodePath = [packageNodeModulesDir, process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
225
+
226
+ return {
227
+ ...projectEnv,
228
+ ...process.env,
229
+ NODE_PATH: nodePath,
230
+ PLAYWRIGHT_CI_RESULTS_DIR: resultsDir,
231
+ PLAYWRIGHT_CI_REPORT_DIR: reportDir,
232
+ PLAYWRIGHT_CI_TEST_DIR: workspaceDir
233
+ };
234
+ }
235
+
236
+ function formatDuration(durationMs = 0) {
237
+ if (durationMs < 1000) {
238
+ return `${Math.round(durationMs)}ms`;
239
+ }
240
+
241
+ return `${(durationMs / 1000).toFixed(1)}s`;
242
+ }
243
+
244
+ function escapeMarkdown(value = '') {
245
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
246
+ }
247
+
248
+ function collectSpecs(suites = [], rows = []) {
249
+ for (const suite of suites) {
250
+ for (const spec of suite.specs || []) {
251
+ for (const test of spec.tests || []) {
252
+ const result = test.results?.[test.results.length - 1] || {};
253
+ const annotations = [...(test.annotations || []), ...(result.annotations || [])];
254
+ const caseId = annotations.find((item) => item.type === 'case-id')?.description || '-';
255
+ const priority = annotations.find((item) => item.type === 'priority')?.description || '-';
256
+ const error = result.errors?.[0]?.message || '';
257
+
258
+ rows.push({
259
+ status: result.status || test.status || (spec.ok ? 'passed' : 'failed'),
260
+ caseId,
261
+ priority,
262
+ title: spec.title,
263
+ projectName: test.projectName || '-',
264
+ duration: result.duration || 0,
265
+ file: spec.file || suite.file || '-',
266
+ line: spec.line || 0,
267
+ error
268
+ });
269
+ }
270
+ }
271
+
272
+ collectSpecs(suite.suites || [], rows);
273
+ }
274
+
275
+ return rows;
276
+ }
277
+
278
+ function statusText(status) {
279
+ if (status === 'passed') return '通过';
280
+ if (status === 'skipped') return '跳过';
281
+ if (status === 'timedOut') return '超时';
282
+ if (status === 'failed' || status === 'unexpected') return '失败';
283
+ return status || '-';
284
+ }
285
+
286
+ function writeMarkdownSummary(projectName, caseDir, exitCode) {
287
+ const jsonPath = path.join(resultsDir, 'results.json');
288
+ if (!fs.existsSync(jsonPath)) {
289
+ console.warn(`未找到 Playwright JSON 结果,已跳过 Markdown 摘要:${jsonPath}`);
290
+ return;
291
+ }
292
+
293
+ const result = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
294
+ const stats = result.stats || {};
295
+ const rows = collectSpecs(result.suites || []);
296
+ const summaryPath = path.join(resultsDir, 'summary.md');
297
+ const failedRows = rows.filter((row) => !['passed', 'skipped'].includes(row.status));
298
+ const status = exitCode === 0 && failedRows.length === 0 ? '通过' : '失败';
299
+ const reportPath = path.relative(resultsDir, path.join(reportDir, 'index.html')) || 'index.html';
300
+
301
+ const lines = [
302
+ '# Playwright 测试结果',
303
+ '',
304
+ `- 项目:${projectName}`,
305
+ `- 状态:${status}`,
306
+ `- 开始时间:${stats.startTime || '-'}`,
307
+ `- 总耗时:${formatDuration(stats.duration || 0)}`,
308
+ `- 用例数:${rows.length}`,
309
+ `- 通过:${stats.expected ?? 0}`,
310
+ `- 失败:${stats.unexpected ?? 0}`,
311
+ `- 跳过:${stats.skipped ?? 0}`,
312
+ `- 不稳定:${stats.flaky ?? 0}`,
313
+ `- 用例目录:${path.relative(workspaceDir, caseDir) || caseDir}`,
314
+ `- HTML 报告:${reportPath}`,
315
+ '',
316
+ '## 用例明细',
317
+ '',
318
+ '| 状态 | 用例 ID | 优先级 | 用例 | 浏览器 | 耗时 | 文件 |',
319
+ '| --- | --- | --- | --- | --- | --- | --- |'
320
+ ];
321
+
322
+ for (const row of rows) {
323
+ const file = row.line ? `${row.file}:${row.line}` : row.file;
324
+ lines.push(
325
+ `| ${statusText(row.status)} | ${escapeMarkdown(row.caseId)} | ${escapeMarkdown(row.priority)} | ${escapeMarkdown(row.title)} | ${escapeMarkdown(row.projectName)} | ${formatDuration(row.duration)} | ${escapeMarkdown(file)} |`
326
+ );
327
+ }
328
+
329
+ if (failedRows.length) {
330
+ lines.push('', '## 失败信息', '');
331
+ for (const row of failedRows) {
332
+ lines.push(`### ${row.caseId} ${row.title}`, '');
333
+ lines.push(row.error ? `\`\`\`text\n${row.error.trim()}\n\`\`\`` : '无错误详情。', '');
334
+ }
335
+ }
336
+
337
+ fs.mkdirSync(resultsDir, { recursive: true });
338
+ fs.writeFileSync(summaryPath, `${lines.join('\n')}\n`);
339
+ console.log(`Markdown 测试摘要已生成:${summaryPath}`);
340
+ }
341
+
204
342
  function main() {
205
343
  const config = readConfig();
206
344
  const { projectName, passthroughArgs, skipPull } = parseArgs(process.argv.slice(2));
@@ -225,10 +363,7 @@ function main() {
225
363
  [playwrightCliPath, ...buildPlaywrightArgs(caseDir, project, passthroughArgs)],
226
364
  {
227
365
  cwd: workspaceDir,
228
- env: {
229
- ...(project.env || {}),
230
- ...process.env
231
- },
366
+ env: createPlaywrightEnv(project.env || {}),
232
367
  stdio: 'inherit'
233
368
  }
234
369
  );
@@ -239,7 +374,13 @@ function main() {
239
374
  return;
240
375
  }
241
376
 
242
- process.exit(code ?? 1);
377
+ const exitCode = code ?? 1;
378
+ try {
379
+ writeMarkdownSummary(projectName, caseDir, exitCode);
380
+ } catch (error) {
381
+ console.warn(`Markdown 测试摘要生成失败:${error instanceof Error ? error.message : error}`);
382
+ }
383
+ process.exit(exitCode);
243
384
  });
244
385
  }
245
386