ykq-playwright-ci 0.1.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.
Files changed (47) hide show
  1. package/.env.example +25 -0
  2. package/.nvmrc +1 -0
  3. package/README.md +553 -0
  4. package/bin/playwright-ci.mjs +116 -0
  5. package/cases/doctor-pc/auth/doctor-auth.setup.ts +15 -0
  6. package/cases/doctor-pc/prescription/submit-failure-keeps-drugs.spec.ts +10 -0
  7. package/cases/doctor-pc/smoke/authenticated-home.spec.ts +28 -0
  8. package/cases/doctor-pc/smoke/login-page.spec.ts +21 -0
  9. package/cases/doctor-pc/smoke/prescription-tab.spec.ts +30 -0
  10. package/cases/doctor-pc/test-env/doctor-master-basic.spec.ts +89 -0
  11. package/ci/github-actions/playwright.yml +61 -0
  12. package/docs/auth-runbook.md +44 -0
  13. package/docs/case-map.md +10 -0
  14. package/docs/ci.md +36 -0
  15. package/docs/runbook.md +39 -0
  16. package/docs/selector-convention.md +24 -0
  17. package/docs/test-data.md +23 -0
  18. package/eslint.config.mjs +28 -0
  19. package/package.json +64 -0
  20. package/playwright.config.ts +44 -0
  21. package/projects.config.json +33 -0
  22. package/scripts/configure-project.mjs +280 -0
  23. package/scripts/run-doctor-test.mjs +19 -0
  24. package/scripts/run-project.mjs +252 -0
  25. package/src/api/doctor.api.ts +25 -0
  26. package/src/api/test-data.api.ts +33 -0
  27. package/src/fixtures/auth.fixture.ts +26 -0
  28. package/src/fixtures/base.fixture.ts +14 -0
  29. package/src/fixtures/doctor.fixture.ts +39 -0
  30. package/src/flows/doctor-pc/chat.flow.ts +10 -0
  31. package/src/flows/doctor-pc/login.flow.ts +28 -0
  32. package/src/flows/doctor-pc/medicine.flow.ts +11 -0
  33. package/src/flows/doctor-pc/prescription.flow.ts +19 -0
  34. package/src/mocks/routes/prescription.routes.ts +25 -0
  35. package/src/mocks/websocket/.gitkeep +1 -0
  36. package/src/pages/doctor-pc/chat.page.ts +26 -0
  37. package/src/pages/doctor-pc/drug-dialog.page.ts +28 -0
  38. package/src/pages/doctor-pc/home.page.ts +30 -0
  39. package/src/pages/doctor-pc/login.page.ts +35 -0
  40. package/src/pages/doctor-pc/prescription.page.ts +29 -0
  41. package/src/pages/doctor-pc/worklist.page.ts +21 -0
  42. package/src/pages/shared/.gitkeep +1 -0
  43. package/src/utils/env.ts +78 -0
  44. package/src/utils/evidence.ts +34 -0
  45. package/src/utils/selectors.ts +9 -0
  46. package/src/utils/waiters.ts +11 -0
  47. package/tsconfig.json +14 -0
@@ -0,0 +1,28 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { appEnv, hasDoctorCredentials } from '../../utils/env';
3
+ import { LoginPage } from '../../pages/doctor-pc/login.page';
4
+
5
+ export class LoginFlow {
6
+ private readonly loginPage: LoginPage;
7
+
8
+ constructor(page: Page) {
9
+ this.loginPage = new LoginPage(page);
10
+ }
11
+
12
+ async assertLoginPageLoads(): Promise<void> {
13
+ await this.loginPage.goto();
14
+ await this.loginPage.assertLoaded();
15
+ }
16
+
17
+ async loginWithConfiguredCredentials(): Promise<void> {
18
+ if (!hasDoctorCredentials()) {
19
+ throw new Error('DOCTOR_USERNAME and DOCTOR_PASSWORD are required for automated UI login.');
20
+ }
21
+
22
+ await this.loginPage.loginWithPassword(
23
+ appEnv.doctorUsername!,
24
+ appEnv.doctorPassword!,
25
+ appEnv.doctorOtp
26
+ );
27
+ }
28
+ }
@@ -0,0 +1,11 @@
1
+ import { DrugDialogPage } from '../../pages/doctor-pc/drug-dialog.page';
2
+
3
+ export class MedicineFlow {
4
+ constructor(private readonly drugDialogPage: DrugDialogPage) {}
5
+
6
+ async searchAndSelectDrug(drugName: string): Promise<void> {
7
+ await this.drugDialogPage.assertVisible();
8
+ await this.drugDialogPage.search(drugName);
9
+ await this.drugDialogPage.selectDrug(drugName);
10
+ }
11
+ }
@@ -0,0 +1,19 @@
1
+ import { DrugDialogPage } from '../../pages/doctor-pc/drug-dialog.page';
2
+ import { PrescriptionPage } from '../../pages/doctor-pc/prescription.page';
3
+
4
+ export class PrescriptionFlow {
5
+ constructor(
6
+ private readonly prescriptionPage: PrescriptionPage,
7
+ private readonly drugDialogPage: DrugDialogPage
8
+ ) {}
9
+
10
+ async addDiagnosisAndDrug(diagnosis: string, drugName: string): Promise<void> {
11
+ await this.prescriptionPage.addDiagnosis(diagnosis);
12
+ await this.drugDialogPage.search(drugName);
13
+ await this.drugDialogPage.selectDrug(drugName);
14
+ }
15
+
16
+ async submitPrescription(): Promise<void> {
17
+ await this.prescriptionPage.submit();
18
+ }
19
+ }
@@ -0,0 +1,25 @@
1
+ import type { Page } from '@playwright/test';
2
+
3
+ export interface PrescriptionFailureOptions {
4
+ url?: string | RegExp;
5
+ status?: number;
6
+ body?: Record<string, unknown>;
7
+ }
8
+
9
+ export async function mockPrescriptionSubmitFailure(
10
+ page: Page,
11
+ options: PrescriptionFailureOptions = {}
12
+ ): Promise<void> {
13
+ await page.route(options.url ?? /prescription.*submit|submit.*prescription/i, async (route) => {
14
+ await route.fulfill({
15
+ status: options.status ?? 500,
16
+ contentType: 'application/json',
17
+ body: JSON.stringify(
18
+ options.body ?? {
19
+ code: 'E_PRESCRIPTION_SUBMIT_FAILED',
20
+ message: 'mock prescription submit failure'
21
+ }
22
+ )
23
+ });
24
+ });
25
+ }
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,26 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { expect } from '@playwright/test';
3
+ import { byTestId, visibleFirst } from '../../utils/selectors';
4
+
5
+ export class ChatPage {
6
+ constructor(private readonly page: Page) {}
7
+
8
+ async assertVisible(): Promise<void> {
9
+ await expect(
10
+ this.page.locator(byTestId('chat-panel')).or(this.page.getByText(/聊天|问诊记录/i)).first()
11
+ ).toBeVisible();
12
+ }
13
+
14
+ async sendTextMessage(message: string): Promise<void> {
15
+ const input = visibleFirst(
16
+ this.page.locator(byTestId('chat-input')),
17
+ this.page.getByRole('textbox', { name: /消息|输入/i })
18
+ );
19
+ await input.fill(message);
20
+
21
+ await visibleFirst(
22
+ this.page.locator(byTestId('chat-send-button')),
23
+ this.page.getByRole('button', { name: /发送/i })
24
+ ).click();
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { expect } from '@playwright/test';
3
+ import { byTestId, visibleFirst } from '../../utils/selectors';
4
+
5
+ export class DrugDialogPage {
6
+ constructor(private readonly page: Page) {}
7
+
8
+ async assertVisible(): Promise<void> {
9
+ await expect(
10
+ this.page.locator(byTestId('drug-dialog')).or(this.page.getByText(/药品|搜索药品/i)).first()
11
+ ).toBeVisible();
12
+ }
13
+
14
+ async search(keyword: string): Promise<void> {
15
+ const input = visibleFirst(
16
+ this.page.locator(byTestId('drug-search-input')),
17
+ this.page.getByRole('textbox', { name: /药品|搜索/i })
18
+ );
19
+ await input.fill(keyword);
20
+ }
21
+
22
+ async selectDrug(drugName: string): Promise<void> {
23
+ await visibleFirst(
24
+ this.page.locator(byTestId(`drug-row-${drugName}`)),
25
+ this.page.getByText(drugName)
26
+ ).click();
27
+ }
28
+ }
@@ -0,0 +1,30 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { expect } from '@playwright/test';
3
+ import { appEnv, doctorURL } from '../../utils/env';
4
+ import { visibleFirst } from '../../utils/selectors';
5
+ import { waitForAppReady } from '../../utils/waiters';
6
+
7
+ export class HomePage {
8
+ constructor(private readonly page: Page) {}
9
+
10
+ async goto(): Promise<void> {
11
+ await this.page.goto(doctorURL(appEnv.doctorPublicPath));
12
+ await waitForAppReady(this.page);
13
+ }
14
+
15
+ async assertLoaded(): Promise<void> {
16
+ await expect(this.page.locator('body')).toBeVisible();
17
+ await expect(this.page.locator('body')).not.toHaveText('');
18
+ }
19
+
20
+ async openPrescriptionTab(): Promise<void> {
21
+ const prescriptionTab = visibleFirst(
22
+ this.page.getByRole('tab', { name: /处方|开方/i }),
23
+ this.page.getByRole('button', { name: /处方|开方/i }),
24
+ this.page.getByText(/处方|开方/i)
25
+ );
26
+
27
+ await prescriptionTab.click();
28
+ await waitForAppReady(this.page);
29
+ }
30
+ }
@@ -0,0 +1,35 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { expect } from '@playwright/test';
3
+ import { appEnv, doctorURL } from '../../utils/env';
4
+ import { waitForAppReady } from '../../utils/waiters';
5
+
6
+ export class LoginPage {
7
+ constructor(private readonly page: Page) {}
8
+
9
+ async goto(): Promise<void> {
10
+ await this.page.goto(doctorURL(appEnv.doctorLoginPath));
11
+ await waitForAppReady(this.page);
12
+ }
13
+
14
+ async assertLoaded(): Promise<void> {
15
+ await expect(this.page.locator('body')).toBeVisible();
16
+ await expect(this.page.locator('body')).not.toHaveText('');
17
+ }
18
+
19
+ async loginWithPassword(username: string, password: string, otp?: string): Promise<void> {
20
+ await this.goto();
21
+
22
+ await this.page
23
+ .getByRole('textbox', { name: /账号|用户名|手机号|手机|工号/i })
24
+ .first()
25
+ .fill(username);
26
+ await this.page.getByLabel(/密码/i).first().fill(password);
27
+
28
+ if (otp) {
29
+ await this.page.getByRole('textbox', { name: /验证码|动态码/i }).first().fill(otp);
30
+ }
31
+
32
+ await this.page.getByRole('button', { name: /登录|登入|提交/i }).first().click();
33
+ await waitForAppReady(this.page);
34
+ }
35
+ }
@@ -0,0 +1,29 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { expect } from '@playwright/test';
3
+ import { byTestId, visibleFirst } from '../../utils/selectors';
4
+
5
+ export class PrescriptionPage {
6
+ constructor(private readonly page: Page) {}
7
+
8
+ async assertVisible(): Promise<void> {
9
+ await expect(
10
+ this.page.locator(byTestId('prescription-panel')).or(this.page.getByText(/处方|开方/i)).first()
11
+ ).toBeVisible();
12
+ }
13
+
14
+ async submit(): Promise<void> {
15
+ await visibleFirst(
16
+ this.page.locator(byTestId('prescription-submit-button')),
17
+ this.page.getByRole('button', { name: /提交处方|提交|保存/i })
18
+ ).click();
19
+ }
20
+
21
+ async addDiagnosis(keyword: string): Promise<void> {
22
+ const input = visibleFirst(
23
+ this.page.locator(byTestId('diagnosis-search-input')),
24
+ this.page.getByRole('textbox', { name: /诊断/i })
25
+ );
26
+ await input.fill(keyword);
27
+ await this.page.getByText(keyword).first().click();
28
+ }
29
+ }
@@ -0,0 +1,21 @@
1
+ import type { Locator, Page } from '@playwright/test';
2
+ import { expect } from '@playwright/test';
3
+ import { byTestId } from '../../utils/selectors';
4
+
5
+ export class WorklistPage {
6
+ constructor(private readonly page: Page) {}
7
+
8
+ inquiryCard(inquiryId: string): Locator {
9
+ return this.page.locator(byTestId(`inquiry-card-${inquiryId}`));
10
+ }
11
+
12
+ async assertVisible(): Promise<void> {
13
+ await expect(
14
+ this.page.locator(byTestId('doctor-worklist')).or(this.page.getByText(/问诊中|待接诊/i)).first()
15
+ ).toBeVisible();
16
+ }
17
+
18
+ async openInquiry(inquiryId: string): Promise<void> {
19
+ await this.inquiryCard(inquiryId).click();
20
+ }
21
+ }
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import dotenv from 'dotenv';
3
+
4
+ dotenv.config();
5
+
6
+ export interface AppEnv {
7
+ testEnv: string;
8
+ doctorBaseURL?: string;
9
+ doctorPublicPath: string;
10
+ doctorLoginPath: string;
11
+ storageStatePath: string;
12
+ apiBaseURL?: string;
13
+ testDataApiURL?: string;
14
+ doctorUsername?: string;
15
+ doctorPassword?: string;
16
+ doctorOtp?: string;
17
+ buildSha: string;
18
+ ci: boolean;
19
+ }
20
+
21
+ function optional(name: string): string | undefined {
22
+ const value = process.env[name]?.trim();
23
+ return value ? value : undefined;
24
+ }
25
+
26
+ function normalizePathOrURL(value: string): string {
27
+ if (/^https?:\/\//i.test(value)) {
28
+ return value;
29
+ }
30
+
31
+ return value.startsWith('/') ? value : `/${value}`;
32
+ }
33
+
34
+ function trimTrailingSlash(value?: string): string | undefined {
35
+ return value?.replace(/\/+$/, '');
36
+ }
37
+
38
+ export function resolveAppEnv(): AppEnv {
39
+ return {
40
+ testEnv: optional('TEST_ENV') ?? 'test',
41
+ doctorBaseURL: trimTrailingSlash(optional('DOCTOR_BASE_URL')),
42
+ doctorPublicPath: normalizePathOrURL(optional('DOCTOR_PUBLIC_PATH') ?? '/'),
43
+ doctorLoginPath: normalizePathOrURL(optional('DOCTOR_LOGIN_PATH') ?? '/login'),
44
+ storageStatePath:
45
+ optional('DOCTOR_STORAGE_STATE') ??
46
+ path.join(process.cwd(), 'playwright/.auth/doctor.json'),
47
+ apiBaseURL: trimTrailingSlash(optional('API_BASE_URL')),
48
+ testDataApiURL: trimTrailingSlash(optional('TEST_DATA_API_URL')),
49
+ doctorUsername: optional('DOCTOR_USERNAME'),
50
+ doctorPassword: optional('DOCTOR_PASSWORD'),
51
+ doctorOtp: optional('DOCTOR_OTP'),
52
+ buildSha: optional('BUILD_SHA') ?? 'local',
53
+ ci: Boolean(process.env.CI)
54
+ };
55
+ }
56
+
57
+ export const appEnv = resolveAppEnv();
58
+
59
+ export function hasDoctorBaseURL(): boolean {
60
+ return Boolean(appEnv.doctorBaseURL);
61
+ }
62
+
63
+ export function hasDoctorCredentials(): boolean {
64
+ return Boolean(appEnv.doctorUsername && appEnv.doctorPassword);
65
+ }
66
+
67
+ export function doctorURL(pathOrURL: string): string {
68
+ if (/^https?:\/\//i.test(pathOrURL)) {
69
+ return pathOrURL;
70
+ }
71
+
72
+ if (!appEnv.doctorBaseURL) {
73
+ throw new Error('DOCTOR_BASE_URL is required for doctor PC UI tests.');
74
+ }
75
+
76
+ const normalizedPath = pathOrURL.replace(/^\/+/, '');
77
+ return new URL(normalizedPath, `${appEnv.doctorBaseURL}/`).toString();
78
+ }
@@ -0,0 +1,34 @@
1
+ import type { TestInfo } from '@playwright/test';
2
+ import { appEnv } from './env';
3
+
4
+ export interface CaseMetadata {
5
+ caseId: string;
6
+ sourceId?: string;
7
+ evidenceId: string;
8
+ priority: 'Smoke' | 'P0' | 'P1' | 'P2';
9
+ }
10
+
11
+ export function annotateCase(testInfo: TestInfo, metadata: CaseMetadata): void {
12
+ testInfo.annotations.push(
13
+ { type: 'case-id', description: metadata.caseId },
14
+ { type: 'evidence-id', description: metadata.evidenceId },
15
+ { type: 'priority', description: metadata.priority },
16
+ { type: 'env', description: appEnv.testEnv },
17
+ { type: 'build', description: appEnv.buildSha }
18
+ );
19
+
20
+ if (metadata.sourceId) {
21
+ testInfo.annotations.push({ type: 'source-id', description: metadata.sourceId });
22
+ }
23
+ }
24
+
25
+ export async function attachJson(
26
+ testInfo: TestInfo,
27
+ name: string,
28
+ value: unknown
29
+ ): Promise<void> {
30
+ await testInfo.attach(name, {
31
+ body: JSON.stringify(value, null, 2),
32
+ contentType: 'application/json'
33
+ });
34
+ }
@@ -0,0 +1,9 @@
1
+ import type { Locator } from '@playwright/test';
2
+
3
+ export function byTestId(testId: string): string {
4
+ return `[data-testid="${testId.replace(/"/g, '\\"')}"]`;
5
+ }
6
+
7
+ export function visibleFirst(first: Locator, ...rest: Locator[]): Locator {
8
+ return rest.reduce((current, next) => current.or(next), first).first();
9
+ }
@@ -0,0 +1,11 @@
1
+ import type { Page } from '@playwright/test';
2
+
3
+ export async function waitForAppReady(page: Page): Promise<void> {
4
+ await page.waitForLoadState('domcontentloaded');
5
+
6
+ try {
7
+ await page.waitForLoadState('networkidle', { timeout: 3_000 });
8
+ } catch {
9
+ // Long-polling and websocket pages may never become fully idle.
10
+ }
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "Node",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "resolveJsonModule": true,
10
+ "skipLibCheck": true,
11
+ "types": ["node", "@playwright/test"]
12
+ },
13
+ "include": ["playwright.config.ts", "src/**/*.ts", "cases/**/*.ts"]
14
+ }