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.
- package/.env.example +25 -0
- package/.nvmrc +1 -0
- package/README.md +553 -0
- package/bin/playwright-ci.mjs +116 -0
- package/cases/doctor-pc/auth/doctor-auth.setup.ts +15 -0
- package/cases/doctor-pc/prescription/submit-failure-keeps-drugs.spec.ts +10 -0
- package/cases/doctor-pc/smoke/authenticated-home.spec.ts +28 -0
- package/cases/doctor-pc/smoke/login-page.spec.ts +21 -0
- package/cases/doctor-pc/smoke/prescription-tab.spec.ts +30 -0
- package/cases/doctor-pc/test-env/doctor-master-basic.spec.ts +89 -0
- package/ci/github-actions/playwright.yml +61 -0
- package/docs/auth-runbook.md +44 -0
- package/docs/case-map.md +10 -0
- package/docs/ci.md +36 -0
- package/docs/runbook.md +39 -0
- package/docs/selector-convention.md +24 -0
- package/docs/test-data.md +23 -0
- package/eslint.config.mjs +28 -0
- package/package.json +64 -0
- package/playwright.config.ts +44 -0
- package/projects.config.json +33 -0
- package/scripts/configure-project.mjs +280 -0
- package/scripts/run-doctor-test.mjs +19 -0
- package/scripts/run-project.mjs +252 -0
- package/src/api/doctor.api.ts +25 -0
- package/src/api/test-data.api.ts +33 -0
- package/src/fixtures/auth.fixture.ts +26 -0
- package/src/fixtures/base.fixture.ts +14 -0
- package/src/fixtures/doctor.fixture.ts +39 -0
- package/src/flows/doctor-pc/chat.flow.ts +10 -0
- package/src/flows/doctor-pc/login.flow.ts +28 -0
- package/src/flows/doctor-pc/medicine.flow.ts +11 -0
- package/src/flows/doctor-pc/prescription.flow.ts +19 -0
- package/src/mocks/routes/prescription.routes.ts +25 -0
- package/src/mocks/websocket/.gitkeep +1 -0
- package/src/pages/doctor-pc/chat.page.ts +26 -0
- package/src/pages/doctor-pc/drug-dialog.page.ts +28 -0
- package/src/pages/doctor-pc/home.page.ts +30 -0
- package/src/pages/doctor-pc/login.page.ts +35 -0
- package/src/pages/doctor-pc/prescription.page.ts +29 -0
- package/src/pages/doctor-pc/worklist.page.ts +21 -0
- package/src/pages/shared/.gitkeep +1 -0
- package/src/utils/env.ts +78 -0
- package/src/utils/evidence.ts +34 -0
- package/src/utils/selectors.ts +9 -0
- package/src/utils/waiters.ts +11 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/* global console, process */
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import readline from 'node:readline/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const configPath = process.env.PROJECTS_CONFIG_PATH
|
|
10
|
+
? path.resolve(process.env.PROJECTS_CONFIG_PATH)
|
|
11
|
+
: path.join(rootDir, 'projects.config.json');
|
|
12
|
+
|
|
13
|
+
function createPrompt() {
|
|
14
|
+
if (process.stdin.isTTY) {
|
|
15
|
+
return readline.createInterface({
|
|
16
|
+
input: process.stdin,
|
|
17
|
+
output: process.stdout
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const answers = fs.readFileSync(0, 'utf8').split(/\r?\n/);
|
|
22
|
+
let index = 0;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
question: async (question) => {
|
|
26
|
+
process.stdout.write(question);
|
|
27
|
+
const answer = answers[index] ?? '';
|
|
28
|
+
index += 1;
|
|
29
|
+
process.stdout.write(`${answer}\n`);
|
|
30
|
+
return answer;
|
|
31
|
+
},
|
|
32
|
+
close: () => {}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readConfig() {
|
|
37
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeConfig(config) {
|
|
41
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeProjectName(value) {
|
|
45
|
+
return value.trim().replace(/\s+/g, '-');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultCaseDir(projectName) {
|
|
49
|
+
return `.case-repos/${projectName}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseEnvPairs(lines) {
|
|
53
|
+
const env = {};
|
|
54
|
+
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (!trimmed) continue;
|
|
58
|
+
|
|
59
|
+
const separatorIndex = trimmed.indexOf('=');
|
|
60
|
+
if (separatorIndex <= 0) {
|
|
61
|
+
throw new Error(`环境变量格式错误:${line}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
65
|
+
const value = trimmed.slice(separatorIndex + 1).trim();
|
|
66
|
+
if (!key) {
|
|
67
|
+
throw new Error(`环境变量 key 不能为空:${line}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
env[key] = value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return env;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function splitArgs(value) {
|
|
77
|
+
return value
|
|
78
|
+
.split(/\s+/)
|
|
79
|
+
.map((item) => item.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printProjects(config) {
|
|
84
|
+
const projects = config.projects || {};
|
|
85
|
+
const names = Object.keys(projects);
|
|
86
|
+
|
|
87
|
+
if (!names.length) {
|
|
88
|
+
console.log('暂无项目配置。');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('项目配置:');
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
const project = projects[name];
|
|
95
|
+
console.log(`- ${name}`);
|
|
96
|
+
console.log(` 说明:${project.description || '-'}`);
|
|
97
|
+
console.log(` 仓库:${project.repo || '-'}`);
|
|
98
|
+
console.log(` 分支:${project.branch || '-'}`);
|
|
99
|
+
console.log(` 用例:${project.caseDir || '-'}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function showProject(config, projectName) {
|
|
104
|
+
const project = config.projects?.[projectName];
|
|
105
|
+
if (!project) {
|
|
106
|
+
throw new Error(`项目不存在:${projectName}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(JSON.stringify({ [projectName]: project }, null, 2));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function askRequired(rl, question, defaultValue = '') {
|
|
113
|
+
const suffix = defaultValue ? ` (${defaultValue})` : '';
|
|
114
|
+
const answer = (await rl.question(`${question}${suffix}: `)).trim();
|
|
115
|
+
const value = answer || defaultValue;
|
|
116
|
+
|
|
117
|
+
if (!value) {
|
|
118
|
+
throw new Error(`${question} 不能为空。`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function askOptional(rl, question, defaultValue = '') {
|
|
125
|
+
const suffix = defaultValue ? ` (${defaultValue})` : '';
|
|
126
|
+
const answer = (await rl.question(`${question}${suffix}: `)).trim();
|
|
127
|
+
return answer || defaultValue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function askEnv(rl, existingEnv = {}, allowKeep = false) {
|
|
131
|
+
const existingKeys = Object.keys(existingEnv);
|
|
132
|
+
if (allowKeep && existingKeys.length) {
|
|
133
|
+
const keep = await askOptional(rl, '是否保留现有环境变量?输入 no 重新填写', 'yes');
|
|
134
|
+
if (keep !== 'no') {
|
|
135
|
+
return existingEnv;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log('请输入环境变量,每行一个 KEY=VALUE;空行结束。');
|
|
140
|
+
|
|
141
|
+
const lines = [];
|
|
142
|
+
while (true) {
|
|
143
|
+
const line = await rl.question('env> ');
|
|
144
|
+
if (!line.trim()) break;
|
|
145
|
+
lines.push(line);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return parseEnvPairs(lines);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function askProjectName(rl, inputName = '') {
|
|
152
|
+
const rawName = inputName || (await askRequired(rl, '项目名,例如 doctor-pc'));
|
|
153
|
+
const projectName = normalizeProjectName(rawName);
|
|
154
|
+
|
|
155
|
+
if (!projectName) {
|
|
156
|
+
throw new Error('项目名不能为空。');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return projectName;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function collectProjectFields(rl, projectName, existing = undefined) {
|
|
163
|
+
const repo = await askRequired(
|
|
164
|
+
rl,
|
|
165
|
+
'用例库 Git 地址',
|
|
166
|
+
existing?.repo || `https://git.int.ybm100.com/ykq/${projectName}-test-store`
|
|
167
|
+
);
|
|
168
|
+
const branch = await askRequired(rl, '分支', existing?.branch || 'master');
|
|
169
|
+
const caseDir = await askRequired(rl, '本地用例缓存目录', existing?.caseDir || defaultCaseDir(projectName));
|
|
170
|
+
const description = await askOptional(rl, '项目说明', existing?.description || `${projectName} 用例库`);
|
|
171
|
+
const playwrightArgsInput = await askOptional(
|
|
172
|
+
rl,
|
|
173
|
+
'Playwright 参数',
|
|
174
|
+
(existing?.playwrightArgs || ['--project=chromium']).join(' ')
|
|
175
|
+
);
|
|
176
|
+
const env = await askEnv(rl, existing?.env || {}, Boolean(existing));
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
description,
|
|
180
|
+
repo,
|
|
181
|
+
branch,
|
|
182
|
+
caseDir,
|
|
183
|
+
env,
|
|
184
|
+
playwrightArgs: splitArgs(playwrightArgsInput)
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function addProject(config, rl, inputName = '') {
|
|
189
|
+
const projectName = await askProjectName(rl, inputName);
|
|
190
|
+
const existing = config.projects?.[projectName];
|
|
191
|
+
|
|
192
|
+
if (existing) {
|
|
193
|
+
throw new Error(`项目已存在:${projectName}。请使用 project:update。`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
config.projects = config.projects || {};
|
|
197
|
+
config.projects[projectName] = await collectProjectFields(rl, projectName);
|
|
198
|
+
writeConfig(config);
|
|
199
|
+
console.log(`已新增项目:${projectName}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function updateProject(config, rl, inputName = '') {
|
|
203
|
+
const projectName = await askProjectName(rl, inputName);
|
|
204
|
+
const existing = config.projects?.[projectName];
|
|
205
|
+
|
|
206
|
+
if (!existing) {
|
|
207
|
+
throw new Error(`项目不存在:${projectName}。请使用 project:add。`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
config.projects[projectName] = await collectProjectFields(rl, projectName, existing);
|
|
211
|
+
writeConfig(config);
|
|
212
|
+
console.log(`已更新项目:${projectName}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function deleteProject(config, rl, inputName = '') {
|
|
216
|
+
const projectName = await askProjectName(rl, inputName);
|
|
217
|
+
const existing = config.projects?.[projectName];
|
|
218
|
+
|
|
219
|
+
if (!existing) {
|
|
220
|
+
throw new Error(`项目不存在:${projectName}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const confirm = await askOptional(rl, `确认删除 ${projectName} 配置?输入 delete 确认`, 'no');
|
|
224
|
+
if (confirm !== 'delete') {
|
|
225
|
+
console.log('已取消。');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
delete config.projects[projectName];
|
|
230
|
+
writeConfig(config);
|
|
231
|
+
console.log(`已删除项目配置:${projectName}`);
|
|
232
|
+
console.log(`本地用例缓存未删除,如需清理可手动删除:${existing.caseDir}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function run() {
|
|
236
|
+
const [action = 'list', projectName = ''] = process.argv.slice(2);
|
|
237
|
+
const config = readConfig();
|
|
238
|
+
|
|
239
|
+
if (action === 'list') {
|
|
240
|
+
printProjects(config);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (action === 'show') {
|
|
245
|
+
const name = normalizeProjectName(projectName);
|
|
246
|
+
if (!name) {
|
|
247
|
+
throw new Error('请提供项目名,例如:npm run project:show -- doctor-pc');
|
|
248
|
+
}
|
|
249
|
+
showProject(config, name);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const rl = createPrompt();
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
if (action === 'add') {
|
|
257
|
+
await addProject(config, rl, projectName);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (action === 'update') {
|
|
262
|
+
await updateProject(config, rl, projectName);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (action === 'delete' || action === 'remove') {
|
|
267
|
+
await deleteProject(config, rl, projectName);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
throw new Error(`未知命令:${action}`);
|
|
272
|
+
} finally {
|
|
273
|
+
rl.close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
run().catch((error) => {
|
|
278
|
+
console.error(error instanceof Error ? error.message : error);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* global process */
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const args = ['scripts/run-project.mjs', 'doctor-pc', ...process.argv.slice(2)];
|
|
6
|
+
|
|
7
|
+
const child = spawn(process.execPath, args, {
|
|
8
|
+
env: process.env,
|
|
9
|
+
stdio: 'inherit'
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
child.on('exit', (code, signal) => {
|
|
13
|
+
if (signal) {
|
|
14
|
+
process.kill(process.pid, signal);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
process.exit(code ?? 1);
|
|
19
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/* global console, process */
|
|
2
|
+
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const configPath = process.env.PROJECTS_CONFIG_PATH
|
|
13
|
+
? path.resolve(process.env.PROJECTS_CONFIG_PATH)
|
|
14
|
+
: path.join(rootDir, 'projects.config.json');
|
|
15
|
+
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');
|
|
18
|
+
const knownModeArgs = new Set(['--headed', '--debug', '--ui']);
|
|
19
|
+
let gitAskpassPath = '';
|
|
20
|
+
|
|
21
|
+
function readConfig() {
|
|
22
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
let projectName = '';
|
|
27
|
+
const passthroughArgs = [];
|
|
28
|
+
let skipPull = false;
|
|
29
|
+
|
|
30
|
+
for (const arg of argv) {
|
|
31
|
+
if (arg === '--no-pull') {
|
|
32
|
+
skipPull = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!arg.startsWith('-') && !projectName) {
|
|
37
|
+
projectName = arg;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
passthroughArgs.push(arg);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
projectName,
|
|
46
|
+
passthroughArgs,
|
|
47
|
+
skipPull
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createGitAskpassEnv() {
|
|
52
|
+
if (!process.env.GIT_USERNAME || !process.env.GIT_PASSWORD) {
|
|
53
|
+
return {
|
|
54
|
+
...process.env,
|
|
55
|
+
GIT_TERMINAL_PROMPT: '0'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!gitAskpassPath) {
|
|
60
|
+
const askpassDir = fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-ci-git-askpass-'));
|
|
61
|
+
gitAskpassPath = path.join(askpassDir, 'askpass.sh');
|
|
62
|
+
fs.writeFileSync(
|
|
63
|
+
gitAskpassPath,
|
|
64
|
+
[
|
|
65
|
+
'#!/bin/sh',
|
|
66
|
+
'case "$1" in',
|
|
67
|
+
'*Username*) printf "%s\\n" "$GIT_USERNAME" ;;',
|
|
68
|
+
'*Password*) printf "%s\\n" "$GIT_PASSWORD" ;;',
|
|
69
|
+
'*) printf "\\n" ;;',
|
|
70
|
+
'esac',
|
|
71
|
+
''
|
|
72
|
+
].join('\n'),
|
|
73
|
+
{ mode: 0o700 }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
...process.env,
|
|
79
|
+
GIT_ASKPASS: gitAskpassPath,
|
|
80
|
+
GIT_TERMINAL_PROMPT: '0'
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cleanupGitAskpass() {
|
|
85
|
+
if (!gitAskpassPath) return;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
fs.rmSync(path.dirname(gitAskpassPath), { recursive: true, force: true });
|
|
89
|
+
} catch {
|
|
90
|
+
// Best-effort cleanup only.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function runGit(args, cwd = workspaceDir) {
|
|
95
|
+
const result = spawnSync('git', args, {
|
|
96
|
+
cwd,
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
env: createGitAskpassEnv(),
|
|
99
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (result.status !== 0) {
|
|
103
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
104
|
+
throw new Error(`git ${args.join(' ')} failed${output ? `:\n${output}` : ''}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return result.stdout.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isEmptyDirectory(dir) {
|
|
111
|
+
return fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ensureCleanCaseRepo(caseDir) {
|
|
115
|
+
if (!fs.existsSync(path.join(caseDir, '.git'))) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const status = runGit(['status', '--porcelain'], caseDir);
|
|
120
|
+
if (status) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`用例库有未提交改动,已停止自动拉取,避免覆盖本地调试内容:${caseDir}\n${status}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function syncCaseRepo(projectName, project, config, skipPull) {
|
|
128
|
+
if (!project.repo) {
|
|
129
|
+
return resolveCaseDir(project.caseDir);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const caseCacheDir = path.resolve(workspaceDir, config.caseCacheDir || '.case-repos');
|
|
133
|
+
const caseDir = resolveCaseDir(project.caseDir || path.join(caseCacheDir, projectName));
|
|
134
|
+
const branch = project.branch || 'main';
|
|
135
|
+
|
|
136
|
+
fs.mkdirSync(path.dirname(caseDir), { recursive: true });
|
|
137
|
+
|
|
138
|
+
if (!fs.existsSync(caseDir)) {
|
|
139
|
+
runGit(['clone', '--branch', branch, '--single-branch', project.repo, caseDir]);
|
|
140
|
+
return caseDir;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!fs.existsSync(path.join(caseDir, '.git')) && isEmptyDirectory(caseDir)) {
|
|
144
|
+
fs.rmdirSync(caseDir);
|
|
145
|
+
runGit(['clone', '--branch', branch, '--single-branch', project.repo, caseDir]);
|
|
146
|
+
return caseDir;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!fs.existsSync(path.join(caseDir, '.git'))) {
|
|
150
|
+
throw new Error(`caseDir 已存在但不是 Git 仓库:${caseDir}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!skipPull) {
|
|
154
|
+
ensureCleanCaseRepo(caseDir);
|
|
155
|
+
runGit(['fetch', 'origin', branch], caseDir);
|
|
156
|
+
runGit(['checkout', branch], caseDir);
|
|
157
|
+
runGit(['pull', '--ff-only', 'origin', branch], caseDir);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return caseDir;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveCaseDir(caseDir) {
|
|
164
|
+
if (!caseDir) {
|
|
165
|
+
throw new Error('项目缺少 caseDir 配置。');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return path.isAbsolute(caseDir) ? caseDir : path.resolve(workspaceDir, caseDir);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function printProjects(config) {
|
|
172
|
+
console.log('可用项目:');
|
|
173
|
+
for (const [name, project] of Object.entries(config.projects || {})) {
|
|
174
|
+
console.log(`- ${name}: ${project.description || ''}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validateProject(projectName, project, caseDir) {
|
|
179
|
+
if (!project) {
|
|
180
|
+
throw new Error(`未知项目:${projectName}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!fs.existsSync(caseDir)) {
|
|
184
|
+
const repoHint = project.repo ? '' : ';如果这是远程用例库项目,请先在 projects.config.json 里配置 repo';
|
|
185
|
+
throw new Error(`用例目录不存在:${caseDir}${repoHint}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildPlaywrightArgs(caseDir, project, passthroughArgs) {
|
|
190
|
+
const args = ['test', caseDir, '--config', playwrightConfigPath];
|
|
191
|
+
args.push(...(project.playwrightArgs || []));
|
|
192
|
+
|
|
193
|
+
for (const arg of passthroughArgs) {
|
|
194
|
+
if (knownModeArgs.has(arg) || arg.startsWith('-')) {
|
|
195
|
+
args.push(arg);
|
|
196
|
+
} else {
|
|
197
|
+
args.push(arg);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return args;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function main() {
|
|
205
|
+
const config = readConfig();
|
|
206
|
+
const { projectName, passthroughArgs, skipPull } = parseArgs(process.argv.slice(2));
|
|
207
|
+
|
|
208
|
+
if (!projectName || projectName === 'list') {
|
|
209
|
+
printProjects(config);
|
|
210
|
+
process.exit(projectName ? 0 : 1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const project = config.projects?.[projectName];
|
|
214
|
+
if (!project) {
|
|
215
|
+
printProjects(config);
|
|
216
|
+
throw new Error(`未知项目:${projectName}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const caseDir = syncCaseRepo(projectName, project, config, skipPull);
|
|
220
|
+
cleanupGitAskpass();
|
|
221
|
+
validateProject(projectName, project, caseDir);
|
|
222
|
+
|
|
223
|
+
const child = spawn(
|
|
224
|
+
process.execPath,
|
|
225
|
+
[playwrightCliPath, ...buildPlaywrightArgs(caseDir, project, passthroughArgs)],
|
|
226
|
+
{
|
|
227
|
+
cwd: workspaceDir,
|
|
228
|
+
env: {
|
|
229
|
+
...(project.env || {}),
|
|
230
|
+
...process.env
|
|
231
|
+
},
|
|
232
|
+
stdio: 'inherit'
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
child.on('exit', (code, signal) => {
|
|
237
|
+
if (signal) {
|
|
238
|
+
process.kill(process.pid, signal);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
process.exit(code ?? 1);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
main();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error(error instanceof Error ? error.message : error);
|
|
250
|
+
cleanupGitAskpass();
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
|
2
|
+
import { appEnv } from '../utils/env';
|
|
3
|
+
|
|
4
|
+
export class DoctorApi {
|
|
5
|
+
constructor(
|
|
6
|
+
private readonly request: APIRequestContext,
|
|
7
|
+
private readonly baseURL = appEnv.apiBaseURL
|
|
8
|
+
) {}
|
|
9
|
+
|
|
10
|
+
async get(pathname: string): Promise<APIResponse> {
|
|
11
|
+
return this.request.get(this.url(pathname));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async post(pathname: string, data: Record<string, unknown>): Promise<APIResponse> {
|
|
15
|
+
return this.request.post(this.url(pathname), { data });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private url(pathname: string): string {
|
|
19
|
+
if (!this.baseURL) {
|
|
20
|
+
throw new Error('API_BASE_URL is required for doctor API assertions.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return new URL(pathname.replace(/^\/+/, ''), `${this.baseURL}/`).toString();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
|
2
|
+
import { appEnv } from '../utils/env';
|
|
3
|
+
|
|
4
|
+
export type TestDataTag =
|
|
5
|
+
| 'doctor-account'
|
|
6
|
+
| 'patient-account'
|
|
7
|
+
| 'first-prescription-inquiry'
|
|
8
|
+
| 'withdrawable-prescription'
|
|
9
|
+
| 'drug-sample'
|
|
10
|
+
| 'auto-close-inquiry';
|
|
11
|
+
|
|
12
|
+
export interface LeaseRequest {
|
|
13
|
+
tag: TestDataTag;
|
|
14
|
+
caseId: string;
|
|
15
|
+
workerIndex?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class TestDataApi {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly request: APIRequestContext,
|
|
21
|
+
private readonly baseURL = appEnv.testDataApiURL
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async lease(request: LeaseRequest): Promise<APIResponse> {
|
|
25
|
+
if (!this.baseURL) {
|
|
26
|
+
throw new Error('TEST_DATA_API_URL is required before leasing test data.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return this.request.post(new URL('leases', `${this.baseURL}/`).toString(), {
|
|
30
|
+
data: request
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
|
4
|
+
import { appEnv } from '../utils/env';
|
|
5
|
+
|
|
6
|
+
export function hasDoctorAuthState(storageStatePath = appEnv.storageStatePath): boolean {
|
|
7
|
+
return fs.existsSync(storageStatePath);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function createDoctorAuthContext(browser: Browser): Promise<BrowserContext> {
|
|
11
|
+
if (!hasDoctorAuthState()) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Doctor storageState is missing: ${appEnv.storageStatePath}. Run npm run auth:doctor or follow docs/auth-runbook.md.`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return browser.newContext({
|
|
18
|
+
baseURL: appEnv.doctorBaseURL,
|
|
19
|
+
storageState: appEnv.storageStatePath
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function saveDoctorAuthState(page: Page): Promise<void> {
|
|
24
|
+
fs.mkdirSync(path.dirname(appEnv.storageStatePath), { recursive: true });
|
|
25
|
+
await page.context().storageState({ path: appEnv.storageStatePath });
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { expect, test as base } from '@playwright/test';
|
|
2
|
+
import { appEnv, type AppEnv } from '../utils/env';
|
|
3
|
+
|
|
4
|
+
export interface BaseFixtures {
|
|
5
|
+
appEnv: AppEnv;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const test = base.extend<BaseFixtures>({
|
|
9
|
+
appEnv: async ({}, use) => {
|
|
10
|
+
await use(appEnv);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export { expect };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test as base } from './base.fixture';
|
|
2
|
+
import { ChatPage } from '../pages/doctor-pc/chat.page';
|
|
3
|
+
import { DrugDialogPage } from '../pages/doctor-pc/drug-dialog.page';
|
|
4
|
+
import { HomePage } from '../pages/doctor-pc/home.page';
|
|
5
|
+
import { LoginPage } from '../pages/doctor-pc/login.page';
|
|
6
|
+
import { PrescriptionPage } from '../pages/doctor-pc/prescription.page';
|
|
7
|
+
import { WorklistPage } from '../pages/doctor-pc/worklist.page';
|
|
8
|
+
|
|
9
|
+
export interface DoctorFixtures {
|
|
10
|
+
loginPage: LoginPage;
|
|
11
|
+
homePage: HomePage;
|
|
12
|
+
worklistPage: WorklistPage;
|
|
13
|
+
chatPage: ChatPage;
|
|
14
|
+
prescriptionPage: PrescriptionPage;
|
|
15
|
+
drugDialogPage: DrugDialogPage;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const test = base.extend<DoctorFixtures>({
|
|
19
|
+
loginPage: async ({ page }, use) => {
|
|
20
|
+
await use(new LoginPage(page));
|
|
21
|
+
},
|
|
22
|
+
homePage: async ({ page }, use) => {
|
|
23
|
+
await use(new HomePage(page));
|
|
24
|
+
},
|
|
25
|
+
worklistPage: async ({ page }, use) => {
|
|
26
|
+
await use(new WorklistPage(page));
|
|
27
|
+
},
|
|
28
|
+
chatPage: async ({ page }, use) => {
|
|
29
|
+
await use(new ChatPage(page));
|
|
30
|
+
},
|
|
31
|
+
prescriptionPage: async ({ page }, use) => {
|
|
32
|
+
await use(new PrescriptionPage(page));
|
|
33
|
+
},
|
|
34
|
+
drugDialogPage: async ({ page }, use) => {
|
|
35
|
+
await use(new DrugDialogPage(page));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export { expect };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ChatPage } from '../../pages/doctor-pc/chat.page';
|
|
2
|
+
|
|
3
|
+
export class ChatFlow {
|
|
4
|
+
constructor(private readonly chatPage: ChatPage) {}
|
|
5
|
+
|
|
6
|
+
async sendDoctorTextMessage(message: string): Promise<void> {
|
|
7
|
+
await this.chatPage.assertVisible();
|
|
8
|
+
await this.chatPage.sendTextMessage(message);
|
|
9
|
+
}
|
|
10
|
+
}
|