qaify-playwright-reporter 0.3.1 → 0.4.1
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 +8 -2
- package/dist/audit-static.d.ts +136 -0
- package/dist/audit-static.js +994 -0
- package/dist/audit.d.ts +125 -2
- package/dist/audit.js +87 -19
- package/dist/cli.js +16 -1
- package/dist/init.d.ts +1 -1
- package/dist/init.js +1 -1
- package/package.json +1 -1
- package/templates/qaify-audit.yml +21 -9
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
// Audit aplikasi tanpa AI (qaify audit-static): pemeriksaan deterministik di repo runner bila
|
|
2
|
+
// CLAUDE_CODE_OAUTH_TOKEN kosong. Hasilnya berbentuk sama dengan keluaran `claude -p`, jadi audit-submit,
|
|
3
|
+
// backend, dan UI tidak berubah. Temuan lebih dangkal dari mode Claude: tidak ada analisis logika bisnis
|
|
4
|
+
// atau kontrak API dari membaca kode.
|
|
5
|
+
// BE (Laravel): composer install, migrate --seed, php artisan test, composer audit, smoke route GET publik.
|
|
6
|
+
// FE (Node): npm ci, npm run build, npm audit, smoke Playwright (desktop + mobile) dengan screenshot setiap halaman,
|
|
7
|
+
// plus foto bertanda (kotak merah di elemen bermasalah / banner pesan error) untuk setiap masalah.
|
|
8
|
+
// Temuan smoke diberi berkas sumber bila bisa ditebak: route FE → komponen halaman, route BE → method controller;
|
|
9
|
+
// request FE yang gagal juga dipetakan ke controller BE, dan route BE yang error ke halaman FE pemanggilnya.
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { openSync } from 'node:fs';
|
|
13
|
+
import { dirname, join, relative } from 'node:path';
|
|
14
|
+
/** usage.model untuk audit tanpa AI; dipakai backend/PDF untuk menampilkan mode audit. */
|
|
15
|
+
export const STATIC_MODEL = 'qaify-static';
|
|
16
|
+
const MAX_FINDINGS = 40;
|
|
17
|
+
const MAX_SMOKE_PAGES = 15;
|
|
18
|
+
const MAX_SMOKE_ROUTES = 40;
|
|
19
|
+
/** Foto bertanda per halaman per viewport (satu per jenis masalah). */
|
|
20
|
+
const MAX_ISSUE_SHOTS = 3;
|
|
21
|
+
const BE_URL = 'http://127.0.0.1:8000';
|
|
22
|
+
const FE_URL = 'http://127.0.0.1:5173';
|
|
23
|
+
const VIEWPORTS = [
|
|
24
|
+
{ name: 'desktop', width: 1366, height: 768 },
|
|
25
|
+
{ name: 'mobile', width: 390, height: 844 },
|
|
26
|
+
];
|
|
27
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'];
|
|
28
|
+
const PENALTY = { critical: 25, high: 10, medium: 4, low: 1 };
|
|
29
|
+
const MIN = 60_000;
|
|
30
|
+
/** Folder kerja hasil audit-context: apps/fe, apps/be, context.json, evidence/. */
|
|
31
|
+
export async function auditStatic(dir, deps) {
|
|
32
|
+
await mkdir(join(dir, '.qaify'), { recursive: true });
|
|
33
|
+
await mkdir(join(dir, 'evidence'), { recursive: true });
|
|
34
|
+
const context = await readFile(join(dir, 'context.json'), 'utf8').then(JSON.parse, () => ({}));
|
|
35
|
+
const login = parseLogin(String(context?.project?.instructions ?? ''));
|
|
36
|
+
const beRoot = join(dir, 'apps', 'be');
|
|
37
|
+
const feRoot = join(dir, 'apps', 'fe');
|
|
38
|
+
const be = await auditBe(beRoot, dir, deps);
|
|
39
|
+
const api = be.routes
|
|
40
|
+
? async (method, path) => {
|
|
41
|
+
const action = matchRoute(be.routes, method, path);
|
|
42
|
+
return action ? controllerLocation(beRoot, action) : null;
|
|
43
|
+
}
|
|
44
|
+
: undefined;
|
|
45
|
+
let fe;
|
|
46
|
+
try {
|
|
47
|
+
fe = await auditFe(feRoot, dir, deps, login, api);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
be.stop();
|
|
51
|
+
}
|
|
52
|
+
await linkCallers(be.failed, fe, feRoot);
|
|
53
|
+
const findings = [...be.result.findings, ...fe.findings].sort(bySeverity).slice(0, MAX_FINDINGS);
|
|
54
|
+
return {
|
|
55
|
+
summary: { be: summarize('BE', be.result), fe: summarize('FE', fe) },
|
|
56
|
+
findings,
|
|
57
|
+
pages: fe.pages ?? [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Bentuk keluaran `claude -p --output-format json` agar parseAuditOutput / audit-submit tidak berubah. */
|
|
61
|
+
export function toAuditOutput(audit, durationMs) {
|
|
62
|
+
return JSON.stringify({
|
|
63
|
+
type: 'result',
|
|
64
|
+
subtype: 'success',
|
|
65
|
+
duration_ms: durationMs,
|
|
66
|
+
structured_output: audit,
|
|
67
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
68
|
+
modelUsage: { [STATIC_MODEL]: { outputTokens: 0 } },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Route BE yang error saat smoke API → halaman FE yang memanggilnya: halaman jadi `page`, komponennya masuk
|
|
73
|
+
* related_files, dan foto halaman itu jadi bukti agar temuan BE juga tampak di laporan.
|
|
74
|
+
*/
|
|
75
|
+
async function linkCallers(failed, fe, feRoot) {
|
|
76
|
+
for (const { finding, uri } of failed) {
|
|
77
|
+
const callers = [...(fe.calls ?? [])].filter(([, calls]) => calls.has(`GET /${uri.replace(/^\//, '')}`)).map(([path]) => path);
|
|
78
|
+
if (!callers.length)
|
|
79
|
+
continue;
|
|
80
|
+
finding.page = { path: callers[0], viewport: 'desktop' };
|
|
81
|
+
finding.description += `\nDipanggil FE di: ${callers.slice(0, 5).join(', ')}.`;
|
|
82
|
+
const related = [];
|
|
83
|
+
for (const path of callers.slice(0, 3)) {
|
|
84
|
+
const location = await resolveRouteFile(feRoot, path).catch(() => null);
|
|
85
|
+
if (location && !related.some((r) => r.file === location.file))
|
|
86
|
+
related.push({ repo: 'fe', ...location, role: 'page' });
|
|
87
|
+
}
|
|
88
|
+
if (related.length)
|
|
89
|
+
finding.related_files = related;
|
|
90
|
+
const shots = (fe.pages ?? [])
|
|
91
|
+
.filter((p) => p.path === callers[0] && p.screenshot)
|
|
92
|
+
.map((p) => ({ path: p.screenshot, viewport: p.viewport, caption: `Halaman FE ${p.path} yang memanggil endpoint ini` }));
|
|
93
|
+
if (shots.length)
|
|
94
|
+
finding.evidence = { ...finding.evidence, screenshot: shots[0].path, screenshots: shots };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function auditBe(root, dir, deps) {
|
|
98
|
+
const result = { findings: [], booted: false, notes: [] };
|
|
99
|
+
const none = { result, stop: () => { }, routes: '', failed: [] };
|
|
100
|
+
const { shell, log } = deps;
|
|
101
|
+
if (!(await exists(root))) {
|
|
102
|
+
result.notes.push('Repo BE tidak ada di folder kerja.');
|
|
103
|
+
return none;
|
|
104
|
+
}
|
|
105
|
+
if (await exists(join(root, 'composer.lock'))) {
|
|
106
|
+
log('qa-factory: [be] composer audit');
|
|
107
|
+
const audit = await shell.run('composer audit --locked --format=json --no-interaction', { cwd: root, timeoutMs: 3 * MIN });
|
|
108
|
+
result.findings.push(...parseComposerAudit(audit.output));
|
|
109
|
+
result.notes.push('composer audit dijalankan.');
|
|
110
|
+
}
|
|
111
|
+
if (!(await exists(join(root, 'artisan')))) {
|
|
112
|
+
result.notes.push('Bukan proyek Laravel (artisan tidak ada): BE tidak dijalankan.');
|
|
113
|
+
return none;
|
|
114
|
+
}
|
|
115
|
+
// .env ke database service runner (MySQL, atau PostgreSQL bila .env.example memakai pgsql)
|
|
116
|
+
const example = await readFile(join(root, '.env.example'), 'utf8').catch(() => '');
|
|
117
|
+
const pgsql = /^DB_CONNECTION=pgsql/m.test(example);
|
|
118
|
+
await writeFile(join(root, '.env'), setEnv(example, {
|
|
119
|
+
APP_ENV: 'local',
|
|
120
|
+
APP_DEBUG: 'true',
|
|
121
|
+
APP_URL: BE_URL,
|
|
122
|
+
DB_CONNECTION: pgsql ? 'pgsql' : 'mysql',
|
|
123
|
+
DB_HOST: '127.0.0.1',
|
|
124
|
+
DB_PORT: pgsql ? '5432' : '3306',
|
|
125
|
+
DB_DATABASE: 'app',
|
|
126
|
+
DB_USERNAME: pgsql ? 'postgres' : 'root',
|
|
127
|
+
DB_PASSWORD: pgsql ? 'postgres' : 'root',
|
|
128
|
+
}));
|
|
129
|
+
log('qa-factory: [be] composer install');
|
|
130
|
+
const install = await shell.run('composer install --no-interaction --no-progress --prefer-dist', { cwd: root, timeoutMs: 10 * MIN });
|
|
131
|
+
if (install.code !== 0) {
|
|
132
|
+
result.findings.push(bootFailure('be', 'composer install gagal', 'Dependensi BE tidak bisa dipasang.', install.output, 'composer.json'));
|
|
133
|
+
result.notes.push('composer install gagal: BE tidak dijalankan.');
|
|
134
|
+
return none;
|
|
135
|
+
}
|
|
136
|
+
log('qa-factory: [be] migrate --seed');
|
|
137
|
+
const migrate = await shell.run('php artisan key:generate --force --no-interaction && php artisan migrate --seed --force --no-interaction', {
|
|
138
|
+
cwd: root,
|
|
139
|
+
timeoutMs: 5 * MIN,
|
|
140
|
+
});
|
|
141
|
+
const migrated = migrate.code === 0;
|
|
142
|
+
if (!migrated) {
|
|
143
|
+
result.findings.push(bootFailure('be', 'Migrasi / seed database gagal', 'php artisan migrate --seed gagal di database kosong.', migrate.output, 'database/migrations'));
|
|
144
|
+
}
|
|
145
|
+
if (await exists(join(root, 'tests'))) {
|
|
146
|
+
log('qa-factory: [be] php artisan test');
|
|
147
|
+
const junit = join(dir, '.qaify', 'be-junit.xml');
|
|
148
|
+
const test = await shell.run(`php artisan test --log-junit "${junit}"`, { cwd: root, timeoutMs: 15 * MIN });
|
|
149
|
+
const xml = await readFile(junit, 'utf8').catch(() => '');
|
|
150
|
+
const failures = parseJunit(xml, root);
|
|
151
|
+
result.findings.push(...failures);
|
|
152
|
+
if (!xml && test.code !== 0) {
|
|
153
|
+
result.findings.push({
|
|
154
|
+
repo: 'be',
|
|
155
|
+
area: 'error_handling',
|
|
156
|
+
severity: 'medium',
|
|
157
|
+
title: 'Test BE tidak bisa dijalankan',
|
|
158
|
+
description: 'php artisan test berhenti sebelum menghasilkan laporan.',
|
|
159
|
+
evidence: { response: tail(test.output) },
|
|
160
|
+
suggestion: 'Jalankan php artisan test secara lokal dan perbaiki konfigurasi test (phpunit.xml, database test).',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
result.notes.push(xml ? `php artisan test: ${failures.length} test gagal.` : 'php artisan test tidak menghasilkan laporan.');
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
result.notes.push('Folder tests tidak ada: test BE dilewati.');
|
|
167
|
+
}
|
|
168
|
+
log('qa-factory: [be] php artisan serve');
|
|
169
|
+
const stop = shell.background(`php artisan serve --host=127.0.0.1 --port=8000`, { cwd: root, logFile: join(dir, '.qaify', 'be-serve.log') });
|
|
170
|
+
if (!(await waitHttp(BE_URL, 30_000, deps.fetch))) {
|
|
171
|
+
result.findings.push(bootFailure('be', 'Server BE tidak bisa dijalankan', 'php artisan serve tidak merespons dalam 30 detik.', await readFile(join(dir, '.qaify', 'be-serve.log'), 'utf8').catch(() => '')));
|
|
172
|
+
return { ...none, stop };
|
|
173
|
+
}
|
|
174
|
+
result.booted = migrated;
|
|
175
|
+
const routes = await shell.run('php artisan route:list --json --no-interaction', { cwd: root, timeoutMs: MIN });
|
|
176
|
+
const smoke = pickSmokeRoutes(routes.output);
|
|
177
|
+
const actions = routeActions(routes.output);
|
|
178
|
+
const failed = [];
|
|
179
|
+
for (const uri of smoke) {
|
|
180
|
+
const url = `${BE_URL}/${uri.replace(/^\//, '')}`;
|
|
181
|
+
const res = await deps.fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) }).catch(() => null);
|
|
182
|
+
if (res && res.status >= 500) {
|
|
183
|
+
const action = actions.get(uri);
|
|
184
|
+
const location = action ? await controllerLocation(root, action) : null;
|
|
185
|
+
const finding = {
|
|
186
|
+
repo: 'be',
|
|
187
|
+
area: 'error_handling',
|
|
188
|
+
severity: 'high',
|
|
189
|
+
title: `GET /${uri} membalas ${res.status}`,
|
|
190
|
+
description: `Route publik GET /${uri}${action ? ` (${action})` : ''} membalas ${res.status} pada database hasil seed.`,
|
|
191
|
+
...location,
|
|
192
|
+
evidence: { request: `GET ${url}`, response: tail(await res.text().catch(() => ''), 2000) },
|
|
193
|
+
suggestion: 'Cek laravel.log untuk exception di route ini dan tangani kasus data kosong / parameter yang hilang.',
|
|
194
|
+
};
|
|
195
|
+
result.findings.push(finding);
|
|
196
|
+
failed.push({ finding, uri });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
result.notes.push(`Smoke API: ${smoke.length} route GET publik dipanggil.`);
|
|
200
|
+
return { result, stop, routes: routes.code === 0 ? routes.output : '', failed };
|
|
201
|
+
}
|
|
202
|
+
// ---------------------------------------------------------------- FE
|
|
203
|
+
async function auditFe(root, dir, deps, login, api) {
|
|
204
|
+
const result = { findings: [], booted: false, notes: [] };
|
|
205
|
+
const { shell, log } = deps;
|
|
206
|
+
const pkg = await readFile(join(root, 'package.json'), 'utf8').then(JSON.parse, () => null);
|
|
207
|
+
if (!pkg) {
|
|
208
|
+
result.notes.push('package.json tidak ada: FE tidak dijalankan.');
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
const scripts = pkg.scripts ?? {};
|
|
212
|
+
const hasLock = await exists(join(root, 'package-lock.json'));
|
|
213
|
+
if (hasLock) {
|
|
214
|
+
log('qa-factory: [fe] npm audit');
|
|
215
|
+
const audit = await shell.run('npm audit --omit=dev --json --package-lock-only', { cwd: root, timeoutMs: 3 * MIN });
|
|
216
|
+
result.findings.push(...parseNpmAudit(audit.output));
|
|
217
|
+
result.notes.push('npm audit dijalankan.');
|
|
218
|
+
}
|
|
219
|
+
log('qa-factory: [fe] npm install');
|
|
220
|
+
let install = await shell.run(hasLock ? 'npm ci --no-audit --no-fund' : 'npm install --no-audit --no-fund', { cwd: root, timeoutMs: 10 * MIN });
|
|
221
|
+
// npm ci gagal umumnya karena package-lock.json tidak sinkron: tetap coba jalankan FE dengan npm install
|
|
222
|
+
if (install.code !== 0 && hasLock) {
|
|
223
|
+
const ci = install;
|
|
224
|
+
log('qa-factory: [fe] npm ci gagal, mencoba npm install');
|
|
225
|
+
install = await shell.run('npm install --no-audit --no-fund', { cwd: root, timeoutMs: 10 * MIN });
|
|
226
|
+
if (install.code === 0) {
|
|
227
|
+
result.findings.push({
|
|
228
|
+
repo: 'fe',
|
|
229
|
+
area: 'error_handling',
|
|
230
|
+
severity: 'medium',
|
|
231
|
+
title: 'npm ci gagal: package-lock.json tidak sinkron',
|
|
232
|
+
description: 'npm ci gagal sehingga build CI/deploy yang memakai lockfile bisa gagal; audit dilanjutkan dengan npm install.',
|
|
233
|
+
file: 'package-lock.json',
|
|
234
|
+
evidence: { response: tail(ci.output) },
|
|
235
|
+
suggestion: 'Jalankan npm install secara lokal lalu commit package-lock.json yang diperbarui.',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (install.code !== 0) {
|
|
240
|
+
result.findings.push(bootFailure('fe', 'Instalasi dependensi FE gagal', 'npm ci / npm install gagal.', install.output, 'package.json'));
|
|
241
|
+
result.notes.push('Instalasi dependensi gagal: FE tidak dijalankan.');
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
// API diarahkan ke BE lokal: setiap VITE_* berisi URL http di .env.example
|
|
245
|
+
const example = (await readFile(join(root, '.env.example'), 'utf8').catch(() => '')) || (await readFile(join(root, '.env'), 'utf8').catch(() => ''));
|
|
246
|
+
await writeFile(join(root, '.env.local'), localApiEnv(example));
|
|
247
|
+
if (scripts.build) {
|
|
248
|
+
log('qa-factory: [fe] npm run build');
|
|
249
|
+
const build = await shell.run('npm run build', { cwd: root, timeoutMs: 10 * MIN });
|
|
250
|
+
if (build.code !== 0) {
|
|
251
|
+
result.findings.push({
|
|
252
|
+
repo: 'fe',
|
|
253
|
+
area: 'ui',
|
|
254
|
+
severity: 'critical',
|
|
255
|
+
title: 'Build FE gagal',
|
|
256
|
+
description: 'npm run build gagal; aplikasi tidak bisa di-deploy dari commit ini.',
|
|
257
|
+
evidence: { response: tail(build.output) },
|
|
258
|
+
suggestion: 'Perbaiki error build / type-check pada log di atas.',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
result.notes.push(build.code === 0 ? 'Build berhasil.' : 'Build gagal.');
|
|
262
|
+
}
|
|
263
|
+
if (!scripts.dev) {
|
|
264
|
+
result.notes.push('Script dev tidak ada: smoke test UI dilewati.');
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
log('qa-factory: [fe] npm run dev');
|
|
268
|
+
const stop = shell.background('npm run dev -- --host 127.0.0.1 --port 5173 --strictPort', { cwd: root, logFile: join(dir, '.qaify', 'fe-dev.log') });
|
|
269
|
+
try {
|
|
270
|
+
if (!(await waitHttp(FE_URL, 60_000, deps.fetch))) {
|
|
271
|
+
result.findings.push(bootFailure('fe', 'Dev server FE tidak bisa dijalankan', 'npm run dev tidak merespons dalam 60 detik.', await readFile(join(dir, '.qaify', 'fe-dev.log'), 'utf8').catch(() => '')));
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
result.booted = true;
|
|
275
|
+
const browser = await deps.launch().catch(() => null);
|
|
276
|
+
if (!browser) {
|
|
277
|
+
result.notes.push('Playwright tidak tersedia: smoke test UI dilewati.');
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const smoke = await smokeUi(browser, FE_URL, join(dir, 'evidence'), login, log, (path) => resolveRouteFile(root, path), api);
|
|
282
|
+
result.findings.push(...smoke.findings);
|
|
283
|
+
result.pages = smoke.visited;
|
|
284
|
+
result.calls = smoke.calls;
|
|
285
|
+
result.notes.push(`Smoke UI: ${smoke.pages} halaman di desktop & mobile${login ? (smoke.loggedIn ? ', login berhasil' : ', login gagal') : ''}.`);
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
await browser.close().catch(() => { });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
stop();
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
const ASSET_TYPES = new Set(['image', 'script', 'stylesheet', 'font']);
|
|
297
|
+
/** Label kotak merah dan keterangan foto bertanda per jenis masalah. */
|
|
298
|
+
const MARK = {
|
|
299
|
+
script: { label: 'error', caption: 'Banner merah: error JavaScript yang tercatat' },
|
|
300
|
+
response: { label: 'API', caption: 'Banner merah: request API yang gagal' },
|
|
301
|
+
asset: { label: 'aset', caption: 'Banner merah: aset yang gagal dimuat' },
|
|
302
|
+
overflow: { label: 'meluap', caption: 'Kotak merah: elemen yang melewati lebar layar' },
|
|
303
|
+
image: { label: 'gambar rusak', caption: 'Kotak merah: gambar yang tidak tampil' },
|
|
304
|
+
covered: { label: 'tertutup', caption: 'Kotak merah: tombol/tautan yang tertutup elemen lain' },
|
|
305
|
+
navigation: { label: 'halaman', caption: 'Banner merah: halaman gagal dimuat' },
|
|
306
|
+
};
|
|
307
|
+
/** Satu temuan per (jenis, halaman); API 5xx dipisah dari 4xx karena severity-nya berbeda. */
|
|
308
|
+
const groupKind = (issue) => (issue.kind === 'response' && (issue.status ?? 0) >= 500 ? 'response5xx' : issue.kind);
|
|
309
|
+
/**
|
|
310
|
+
* Masalah tata letak per elemen di halaman yang sedang terbuka (koordinat dokumen):
|
|
311
|
+
* - overflow: elemen terluar yang melewati tepi kanan layar (induknya masih muat, tidak di dalam kontainer scroll);
|
|
312
|
+
* - image: <img> yang gagal dimuat;
|
|
313
|
+
* - covered: tombol/tautan/input di layar yang titik tengahnya tertutup elemen lain (dikelompokkan per penutup).
|
|
314
|
+
*/
|
|
315
|
+
const LAYOUT_PROBE = `(() => {
|
|
316
|
+
const W = window.innerWidth, H = window.innerHeight, out = []
|
|
317
|
+
const name = (el) => el.id ? '#' + el.id : el.tagName.toLowerCase() + [...el.classList].slice(0, 2).map((c) => '.' + c).join('')
|
|
318
|
+
const box = (r) => ({ x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), width: Math.round(r.width), height: Math.round(r.height) })
|
|
319
|
+
const shown = (el, r) => r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden'
|
|
320
|
+
const scrolls = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) if (/hidden|auto|scroll|clip/.test(getComputedStyle(p).overflowX)) return true; return false }
|
|
321
|
+
if (document.documentElement.scrollWidth > W + 1) {
|
|
322
|
+
let n = 0
|
|
323
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
324
|
+
const r = el.getBoundingClientRect()
|
|
325
|
+
if (r.right <= W + 1 || el.parentElement.getBoundingClientRect().right > W + 1 || !shown(el, r) || scrolls(el)) continue
|
|
326
|
+
out.push({ kind: 'overflow', selector: name(el), rect: box(r), message: name(el) + ' melewati lebar layar (' + Math.round(r.right) + 'px > ' + W + 'px).' })
|
|
327
|
+
if (++n >= 3) break
|
|
328
|
+
}
|
|
329
|
+
if (!n) out.push({ kind: 'overflow', message: 'Konten meluap horizontal (muncul scroll ke samping).' })
|
|
330
|
+
}
|
|
331
|
+
let images = 0
|
|
332
|
+
for (const img of document.images) {
|
|
333
|
+
const r = img.getBoundingClientRect()
|
|
334
|
+
if (!img.complete || img.naturalWidth > 0 || !img.getAttribute('src') || !shown(img, r)) continue
|
|
335
|
+
out.push({ kind: 'image', selector: name(img), rect: box(r), message: 'Gambar tidak tampil: ' + img.getAttribute('src').slice(0, 200) })
|
|
336
|
+
if (++images >= 3) break
|
|
337
|
+
}
|
|
338
|
+
const covers = new Map()
|
|
339
|
+
for (const el of document.querySelectorAll('button:not([disabled]), a[href], input:not([type=hidden]):not([disabled]), select, textarea')) {
|
|
340
|
+
const r = el.getBoundingClientRect()
|
|
341
|
+
if (!shown(el, r) || r.top < 0 || r.left < 0 || r.bottom > H || r.right > W) continue
|
|
342
|
+
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
|
343
|
+
if (!hit || el.contains(hit) || hit.contains(el) || [...(el.labels || [])].some((l) => l.contains(hit))) continue
|
|
344
|
+
const c = covers.get(hit) || { el, r, count: 0 }
|
|
345
|
+
c.count++
|
|
346
|
+
covers.set(hit, c)
|
|
347
|
+
}
|
|
348
|
+
for (const [hit, c] of [...covers].slice(0, 3)) {
|
|
349
|
+
out.push({ kind: 'covered', selector: name(c.el), rect: box(c.r), message: name(hit) + ' menutupi ' + (c.count > 1 ? c.count + ' tombol/tautan, mis. ' : '') + name(c.el) + ' sehingga tidak bisa diklik.' })
|
|
350
|
+
}
|
|
351
|
+
return out
|
|
352
|
+
})()`;
|
|
353
|
+
/** Pasang kotak merah + label di elemen bermasalah, atau banner pesan error, lalu gulir ke tandanya. */
|
|
354
|
+
function annotate(boxes, banner) {
|
|
355
|
+
const scrollY = boxes.length ? Math.max(0, Math.min(...boxes.map((b) => b.y)) - 120) : 0;
|
|
356
|
+
return `((d) => {
|
|
357
|
+
document.getElementById('__qaify')?.remove()
|
|
358
|
+
const root = document.createElement('div')
|
|
359
|
+
root.id = '__qaify'
|
|
360
|
+
root.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;font:600 12px/1.4 system-ui,sans-serif'
|
|
361
|
+
for (const b of d.boxes) {
|
|
362
|
+
const el = document.createElement('div')
|
|
363
|
+
el.style.cssText = 'position:absolute;box-sizing:border-box;border:3px solid #e11d48;background:rgba(225,29,72,.08);border-radius:4px;left:' + (b.x - 4) + 'px;top:' + (b.y - 4) + 'px;width:' + (b.width + 8) + 'px;height:' + (b.height + 8) + 'px'
|
|
364
|
+
const tag = document.createElement('div')
|
|
365
|
+
tag.textContent = b.label
|
|
366
|
+
tag.style.cssText = 'position:absolute;left:-3px;bottom:100%;margin-bottom:2px;background:#e11d48;color:#fff;padding:1px 6px;border-radius:3px;white-space:nowrap'
|
|
367
|
+
el.appendChild(tag)
|
|
368
|
+
root.appendChild(el)
|
|
369
|
+
}
|
|
370
|
+
if (d.banner.length) {
|
|
371
|
+
const el = document.createElement('div')
|
|
372
|
+
el.style.cssText = 'position:fixed;left:8px;right:8px;top:8px;background:#7f1d1d;color:#fff;padding:8px 10px;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,.3);font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;word-break:break-word'
|
|
373
|
+
el.textContent = 'Qaify · ' + d.banner.join('\\n')
|
|
374
|
+
root.appendChild(el)
|
|
375
|
+
}
|
|
376
|
+
document.body.appendChild(root)
|
|
377
|
+
window.scrollTo(0, d.scrollY)
|
|
378
|
+
})(${JSON.stringify({ boxes, banner, scrollY })})`;
|
|
379
|
+
}
|
|
380
|
+
const CLEAR_MARKS = `document.getElementById('__qaify')?.remove(); window.scrollTo(0, 0)`;
|
|
381
|
+
/** Urutan jenis masalah yang difoto bertanda bila melewati batas: tata letak dulu (hanya terlihat dari foto). */
|
|
382
|
+
const SHOT_ORDER = ['overflow', 'covered', 'image', 'script', 'response5xx', 'response', 'asset'];
|
|
383
|
+
/** Foto bertanda untuk masalah halaman ini: satu foto per jenis masalah, maksimal MAX_ISSUE_SHOTS. */
|
|
384
|
+
async function shootIssues(page, found, evidenceDir, prefix) {
|
|
385
|
+
const groups = new Map();
|
|
386
|
+
for (const issue of found)
|
|
387
|
+
if (issue.kind !== 'navigation')
|
|
388
|
+
groups.set(groupKind(issue), [...(groups.get(groupKind(issue)) ?? []), issue]);
|
|
389
|
+
const ordered = [...groups].sort(([a], [b]) => SHOT_ORDER.indexOf(a) - SHOT_ORDER.indexOf(b));
|
|
390
|
+
for (const [kind, group] of ordered.slice(0, MAX_ISSUE_SHOTS)) {
|
|
391
|
+
const boxes = group
|
|
392
|
+
.filter((i) => i.rect)
|
|
393
|
+
.slice(0, 3)
|
|
394
|
+
.map((i) => ({ ...i.rect, label: `${MARK[i.kind].label}: ${i.selector ?? ''}`.slice(0, 60) }));
|
|
395
|
+
const banner = boxes.length ? [] : [...new Set(group.map((i) => i.message.slice(0, 300)))].slice(0, 4);
|
|
396
|
+
const file = `${prefix}-${kind}.png`;
|
|
397
|
+
const ok = await page
|
|
398
|
+
.evaluate(annotate(boxes, banner))
|
|
399
|
+
.then(() => page.screenshot({ path: join(evidenceDir, file) }))
|
|
400
|
+
.then(() => true, () => false);
|
|
401
|
+
await page.evaluate(CLEAR_MARKS).catch(() => { });
|
|
402
|
+
if (ok)
|
|
403
|
+
for (const issue of group)
|
|
404
|
+
issue.shot = `evidence/${file}`;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Jelajah link same-origin (BFS) di desktop, lalu ulangi halaman yang sama di mobile.
|
|
409
|
+
* Setiap halaman difoto polos (galeri laporan); setiap jenis masalah difoto lagi dengan tanda (kotak merah di elemen,
|
|
410
|
+
* atau banner pesan error) sebagai bukti temuannya.
|
|
411
|
+
* locate: route → berkas sumber halaman (best effort) untuk kolom file temuan dan galeri.
|
|
412
|
+
* api: request XHR/fetch yang gagal → controller BE (related_files).
|
|
413
|
+
*/
|
|
414
|
+
export async function smokeUi(browser, baseUrl, evidenceDir, login, log = () => { }, locate = async () => null, api = async () => null) {
|
|
415
|
+
const origin = new URL(baseUrl).origin;
|
|
416
|
+
const issues = [];
|
|
417
|
+
const screenshots = new Map();
|
|
418
|
+
const visited = [];
|
|
419
|
+
const calls = new Map();
|
|
420
|
+
let paths = [];
|
|
421
|
+
let loggedIn = false;
|
|
422
|
+
for (const viewport of VIEWPORTS) {
|
|
423
|
+
const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height } });
|
|
424
|
+
try {
|
|
425
|
+
const page = await context.newPage();
|
|
426
|
+
let current = '/';
|
|
427
|
+
const push = (issue) => issues.push({ ...issue, path: current, viewport: viewport.name });
|
|
428
|
+
// "Failed to load resource" sudah tercatat lewat response (atau aset seperti favicon yang tidak penting)
|
|
429
|
+
page.on('console', (m) => m.type() === 'error' && !/^Failed to load resource/.test(String(m.text())) && push({ kind: 'script', message: String(m.text()) }));
|
|
430
|
+
page.on('pageerror', (e) => push({ kind: 'script', message: String(e?.message ?? e) }));
|
|
431
|
+
page.on('response', (r) => {
|
|
432
|
+
const type = r.request().resourceType();
|
|
433
|
+
const url = String(r.url());
|
|
434
|
+
if (type === 'xhr' || type === 'fetch') {
|
|
435
|
+
const method = String(r.request().method());
|
|
436
|
+
const endpoint = pathnameOf(url) ?? undefined;
|
|
437
|
+
if (endpoint)
|
|
438
|
+
calls.set(current, (calls.get(current) ?? new Set()).add(`${method} ${endpoint}`));
|
|
439
|
+
if (r.status() >= 400)
|
|
440
|
+
push({ kind: 'response', status: r.status(), method, endpoint, message: `${method} ${stripOrigin(url, origin)} → ${r.status()}` });
|
|
441
|
+
}
|
|
442
|
+
else if (ASSET_TYPES.has(type) && r.status() >= 400 && !/favicon/i.test(url)) {
|
|
443
|
+
push({ kind: 'asset', status: r.status(), message: `${type} ${stripOrigin(url, origin)} → ${r.status()}` });
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
// request yang putus (BE mati, CORS, DNS); ERR_ABORTED = dibatalkan karena pindah halaman
|
|
447
|
+
page.on('requestfailed', (req) => {
|
|
448
|
+
const error = String(req.failure?.()?.errorText ?? 'gagal');
|
|
449
|
+
if (/ERR_ABORTED/.test(error))
|
|
450
|
+
return;
|
|
451
|
+
const type = req.resourceType();
|
|
452
|
+
const url = String(req.url());
|
|
453
|
+
if (type === 'xhr' || type === 'fetch') {
|
|
454
|
+
const method = String(req.method());
|
|
455
|
+
push({ kind: 'response', method, endpoint: pathnameOf(url) ?? undefined, message: `${method} ${stripOrigin(url, origin)} → ${error}` });
|
|
456
|
+
}
|
|
457
|
+
else if (ASSET_TYPES.has(type) && !/favicon/i.test(url)) {
|
|
458
|
+
push({ kind: 'asset', message: `${type} ${stripOrigin(url, origin)} → ${error}` });
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
/** Status HTTP halaman; undefined = gagal dimuat. */
|
|
462
|
+
const visit = async (path) => {
|
|
463
|
+
current = path;
|
|
464
|
+
try {
|
|
465
|
+
const res = (await page.goto(origin + path, { waitUntil: 'networkidle', timeout: 20_000 }));
|
|
466
|
+
const status = res?.status?.() ?? 200;
|
|
467
|
+
if (status >= 400)
|
|
468
|
+
push({ kind: 'navigation', message: `Halaman membalas ${status}.` });
|
|
469
|
+
return status;
|
|
470
|
+
}
|
|
471
|
+
catch (e) {
|
|
472
|
+
push({ kind: 'navigation', message: `Halaman gagal dimuat: ${e.message.split('\n')[0]}` });
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
await visit('/');
|
|
477
|
+
if (login && (await page.locator('input[type=password]').count()) > 0) {
|
|
478
|
+
await page.locator('input[type=email], input[name*=email i], input[name*=user i], input[type=text]').first().fill(login.email).catch(() => { });
|
|
479
|
+
const password = page.locator('input[type=password]').first();
|
|
480
|
+
await password.fill(login.password).catch(() => { });
|
|
481
|
+
await password.press('Enter').catch(() => { });
|
|
482
|
+
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => { });
|
|
483
|
+
loggedIn = (await page.locator('input[type=password]').count()) === 0;
|
|
484
|
+
}
|
|
485
|
+
const queue = viewport.name === 'desktop' ? [pathOf(page.url(), origin) ?? '/'] : [...paths];
|
|
486
|
+
const seen = new Set();
|
|
487
|
+
while (queue.length && seen.size < MAX_SMOKE_PAGES) {
|
|
488
|
+
const path = queue.shift();
|
|
489
|
+
if (seen.has(path))
|
|
490
|
+
continue;
|
|
491
|
+
seen.add(path);
|
|
492
|
+
const before = issues.length;
|
|
493
|
+
const status = await visit(path);
|
|
494
|
+
if (status !== undefined && status < 400) {
|
|
495
|
+
const layout = await page.evaluate(LAYOUT_PROBE).catch(() => []);
|
|
496
|
+
for (const item of Array.isArray(layout) ? layout : []) {
|
|
497
|
+
if (item?.kind in MARK)
|
|
498
|
+
push({ kind: item.kind, message: String(item.message), selector: item.selector, rect: item.rect });
|
|
499
|
+
}
|
|
500
|
+
if (viewport.name === 'desktop') {
|
|
501
|
+
const links = (await page.evaluate('[...document.querySelectorAll("a[href]")].map((a) => a.href)').catch(() => []));
|
|
502
|
+
for (const href of Array.isArray(links) ? links : []) {
|
|
503
|
+
const next = pathOf(String(href), origin);
|
|
504
|
+
if (next && !seen.has(next) && !/log-?out|sign-?out|keluar/i.test(next))
|
|
505
|
+
queue.push(next);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const file = `fe-${viewport.name}-${slug(path)}.png`;
|
|
510
|
+
const shot = await page
|
|
511
|
+
.screenshot({ path: join(evidenceDir, file) })
|
|
512
|
+
.then(() => `evidence/${file}`)
|
|
513
|
+
.catch(() => undefined);
|
|
514
|
+
if (shot)
|
|
515
|
+
screenshots.set(`${viewport.name} ${path}`, shot);
|
|
516
|
+
visited.push({ path, viewport: viewport.name, status, issues: issues.length - before, screenshot: shot });
|
|
517
|
+
// foto polos dulu (galeri), baru foto bertanda; error async yang datang belakangan tetap memakai foto polos
|
|
518
|
+
await shootIssues(page, issues.slice(before), evidenceDir, `fe-${viewport.name}-${slug(path)}`);
|
|
519
|
+
}
|
|
520
|
+
if (viewport.name === 'desktop')
|
|
521
|
+
paths = [...seen];
|
|
522
|
+
log(`qa-factory: [fe] smoke ${viewport.name}: ${seen.size} halaman`);
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
await context.close().catch(() => { });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// berkas komponen untuk semua halaman (galeri), bukan hanya yang bermasalah
|
|
529
|
+
const locations = new Map();
|
|
530
|
+
for (const path of new Set([...visited.map((p) => p.path), ...issues.map((i) => i.path)]))
|
|
531
|
+
locations.set(path, await locate(path).catch(() => null));
|
|
532
|
+
for (const page of visited)
|
|
533
|
+
Object.assign(page, locations.get(page.path) ?? {});
|
|
534
|
+
const apis = new Map();
|
|
535
|
+
for (const issue of issues) {
|
|
536
|
+
const key = `${issue.method} ${issue.endpoint}`;
|
|
537
|
+
if (issue.kind === 'response' && issue.method && issue.endpoint && !apis.has(key))
|
|
538
|
+
apis.set(key, await api(issue.method, issue.endpoint).catch(() => null));
|
|
539
|
+
}
|
|
540
|
+
return { findings: issuesToFindings(issues, screenshots, locations, apis), pages: paths.length, loggedIn, visited, calls };
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Satu temuan per (jenis, halaman); pesan sama di beberapa viewport digabung.
|
|
544
|
+
* Bukti: satu foto per viewport, foto bertanda bila ada, foto polos halaman bila tidak.
|
|
545
|
+
*/
|
|
546
|
+
function issuesToFindings(issues, screenshots, locations, apis) {
|
|
547
|
+
const groups = new Map();
|
|
548
|
+
for (const issue of issues) {
|
|
549
|
+
const key = `${groupKind(issue)} ${issue.path}`;
|
|
550
|
+
groups.set(key, [...(groups.get(key) ?? []), issue]);
|
|
551
|
+
}
|
|
552
|
+
return [...groups.values()].map((group) => {
|
|
553
|
+
const first = group[0];
|
|
554
|
+
const viewports = [...new Set(group.map((i) => i.viewport))];
|
|
555
|
+
const messages = [...new Set(group.map((i) => i.message))];
|
|
556
|
+
const shots = viewports.flatMap((v) => {
|
|
557
|
+
const marked = group.find((i) => i.viewport === v && i.shot)?.shot;
|
|
558
|
+
const path = marked ?? screenshots.get(`${v} ${first.path}`);
|
|
559
|
+
return path ? [{ path, viewport: v, caption: marked ? MARK[first.kind].caption : `Tampilan halaman ${first.path}` }] : [];
|
|
560
|
+
});
|
|
561
|
+
const related = [];
|
|
562
|
+
for (const issue of group) {
|
|
563
|
+
const location = apis.get(`${issue.method} ${issue.endpoint}`);
|
|
564
|
+
if (location && !related.some((r) => r.file === location.file))
|
|
565
|
+
related.push({ repo: 'be', ...location, role: 'controller' });
|
|
566
|
+
}
|
|
567
|
+
const where = `${first.path} (${viewports.join(', ')})`;
|
|
568
|
+
const base = {
|
|
569
|
+
repo: 'fe',
|
|
570
|
+
...locations.get(first.path),
|
|
571
|
+
page: { path: first.path, viewport: viewports[0] },
|
|
572
|
+
...(related.length ? { related_files: related.slice(0, 5) } : {}),
|
|
573
|
+
evidence: shots.length ? { screenshot: shots[0].path, screenshots: shots } : undefined,
|
|
574
|
+
};
|
|
575
|
+
const list = messages.slice(0, 5).map((m) => `- ${m}`).join('\n') + (messages.length > 5 ? `\n- … ${messages.length - 5} lainnya` : '');
|
|
576
|
+
switch (first.kind) {
|
|
577
|
+
case 'script':
|
|
578
|
+
return { ...base, area: 'error_handling', severity: 'high', title: `Error JavaScript di ${first.path}`, description: `Error console / JavaScript di ${where}:\n${list}`, suggestion: 'Buka halaman ini dengan DevTools dan perbaiki error yang tercatat.' };
|
|
579
|
+
case 'response':
|
|
580
|
+
return {
|
|
581
|
+
...base,
|
|
582
|
+
area: 'api_contract',
|
|
583
|
+
severity: (first.status ?? 0) >= 500 ? 'high' : 'medium',
|
|
584
|
+
title: `Request API gagal di ${first.path}`,
|
|
585
|
+
description: `Request XHR/fetch gagal saat membuka ${where}:\n${list}${related.length ? `\nDilayani BE di: ${related.map((r) => `${r.file}${r.line ? `:${r.line}` : ''}`).join(', ')}.` : ''}`,
|
|
586
|
+
suggestion: 'Cocokkan endpoint, parameter, dan otorisasi yang dipanggil FE dengan route BE.',
|
|
587
|
+
};
|
|
588
|
+
case 'asset':
|
|
589
|
+
return { ...base, area: 'ui', severity: 'medium', title: `Aset gagal dimuat di ${first.path}`, description: `Gambar/script/CSS/font gagal dimuat di ${where}:\n${list}`, suggestion: 'Periksa path aset, base URL build, dan berkas yang belum ikut ter-deploy.' };
|
|
590
|
+
case 'overflow':
|
|
591
|
+
return { ...base, area: 'ui', severity: 'medium', title: `Tampilan meluap horizontal di ${first.path}`, description: `Lebar konten melebihi layar di ${where}:\n${list}`, suggestion: 'Bungkus tabel/elemen lebar dengan overflow-x-auto atau buat layout responsif.' };
|
|
592
|
+
case 'image':
|
|
593
|
+
return { ...base, area: 'ui', severity: 'low', title: `Gambar tidak tampil di ${first.path}`, description: `Gambar gagal dimuat di ${where}:\n${list}`, suggestion: 'Periksa URL gambar dan sediakan gambar pengganti bila data kosong.' };
|
|
594
|
+
case 'covered':
|
|
595
|
+
return { ...base, area: 'ui', severity: 'medium', title: `Tombol/tautan tertutup elemen lain di ${first.path}`, description: `Kontrol tidak bisa diklik karena tertutup di ${where}:\n${list}`, suggestion: 'Periksa z-index, position fixed/absolute, dan overlay yang tidak tertutup.' };
|
|
596
|
+
default:
|
|
597
|
+
return { ...base, area: 'error_handling', severity: 'high', title: `Halaman ${first.path} gagal dimuat`, description: `${where}:\n${list}`, suggestion: 'Pastikan route FE ada dan tidak menunggu request yang menggantung.' };
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
// ---------------------------------------------------------------- parser & helper (diuji terpisah)
|
|
602
|
+
/** Test gagal / error dari laporan JUnit PHPUnit / Pest. */
|
|
603
|
+
export function parseJunit(xml, root = '') {
|
|
604
|
+
const findings = [];
|
|
605
|
+
for (const match of xml.matchAll(/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g)) {
|
|
606
|
+
const body = match[2] ?? '';
|
|
607
|
+
const failure = /<(failure|error)\b[^>]*?(?:\/>|>([\s\S]*?)<\/\1>)/.exec(body);
|
|
608
|
+
if (!failure)
|
|
609
|
+
continue;
|
|
610
|
+
const attrs = attributes(match[1] ?? '');
|
|
611
|
+
const name = attrs.name ?? 'test';
|
|
612
|
+
const cls = attrs.class ?? attrs.classname ?? '';
|
|
613
|
+
const file = attrs.file ? relativeTo(attrs.file, root) : undefined;
|
|
614
|
+
const message = decode(failure[2] ?? '').trim();
|
|
615
|
+
findings.push({
|
|
616
|
+
repo: 'be',
|
|
617
|
+
area: 'business_logic',
|
|
618
|
+
severity: 'high',
|
|
619
|
+
title: `Test gagal: ${cls ? `${cls.split(/[\\.]/).pop()}::` : ''}${name}`.slice(0, 120),
|
|
620
|
+
description: message.slice(0, 4000) || 'Test gagal tanpa pesan.',
|
|
621
|
+
file,
|
|
622
|
+
line: attrs.line ? Number(attrs.line) : undefined,
|
|
623
|
+
suggestion: 'Jalankan test ini secara lokal; perbaiki kode atau perbarui test bila perilakunya memang berubah.',
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
return findings;
|
|
627
|
+
}
|
|
628
|
+
/** `composer audit --format=json` → satu temuan per advisory. */
|
|
629
|
+
export function parseComposerAudit(output) {
|
|
630
|
+
const data = parseJsonLoose(output);
|
|
631
|
+
const advisories = data?.advisories && typeof data.advisories === 'object' ? data.advisories : {};
|
|
632
|
+
const findings = [];
|
|
633
|
+
for (const [pkg, list] of Object.entries(advisories)) {
|
|
634
|
+
for (const advisory of Array.isArray(list) ? list : Object.values(list ?? {})) {
|
|
635
|
+
const severity = SEVERITY_ORDER.includes(advisory?.severity) ? advisory.severity : 'high';
|
|
636
|
+
findings.push({
|
|
637
|
+
repo: 'be',
|
|
638
|
+
area: 'security',
|
|
639
|
+
severity,
|
|
640
|
+
title: `${pkg}: ${advisory?.title ?? 'kerentanan diketahui'}`.slice(0, 120),
|
|
641
|
+
description: [advisory?.title, advisory?.cve && `CVE: ${advisory.cve}`, advisory?.affectedVersions && `Versi terdampak: ${advisory.affectedVersions}`, advisory?.link]
|
|
642
|
+
.filter(Boolean)
|
|
643
|
+
.join('\n'),
|
|
644
|
+
file: 'composer.lock',
|
|
645
|
+
suggestion: `composer update ${pkg} ke versi yang sudah diperbaiki.`,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return findings;
|
|
650
|
+
}
|
|
651
|
+
/** `npm audit --json` (npm 7+) → satu temuan per paket berseverity high/critical. */
|
|
652
|
+
export function parseNpmAudit(output) {
|
|
653
|
+
const data = parseJsonLoose(output);
|
|
654
|
+
const findings = [];
|
|
655
|
+
for (const [name, vuln] of Object.entries((data?.vulnerabilities ?? {}))) {
|
|
656
|
+
if (vuln?.severity !== 'high' && vuln?.severity !== 'critical')
|
|
657
|
+
continue;
|
|
658
|
+
const advisories = (Array.isArray(vuln.via) ? vuln.via : []).filter((v) => v && typeof v === 'object');
|
|
659
|
+
const fix = vuln.fixAvailable;
|
|
660
|
+
findings.push({
|
|
661
|
+
repo: 'fe',
|
|
662
|
+
area: 'security',
|
|
663
|
+
severity: vuln.severity,
|
|
664
|
+
title: `${name}: ${advisories[0]?.title ?? 'kerentanan diketahui'}`.slice(0, 120),
|
|
665
|
+
description: [
|
|
666
|
+
...advisories.slice(0, 3).map((a) => `${a.title}${a.url ? ` (${a.url})` : ''}`),
|
|
667
|
+
vuln.range && `Versi terdampak: ${vuln.range}`,
|
|
668
|
+
vuln.isDirect ? 'Dependensi langsung.' : 'Dependensi tidak langsung.',
|
|
669
|
+
]
|
|
670
|
+
.filter(Boolean)
|
|
671
|
+
.join('\n'),
|
|
672
|
+
file: 'package-lock.json',
|
|
673
|
+
suggestion: fix && typeof fix === 'object' ? `Perbarui ${fix.name} ke ${fix.version}${fix.isSemVerMajor ? ' (major)' : ''}.` : fix ? 'Jalankan npm audit fix.' : 'Belum ada versi perbaikan; pertimbangkan mengganti paket.',
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return findings;
|
|
677
|
+
}
|
|
678
|
+
/** URI route GET tanpa parameter dan tanpa middleware autentikasi dari `route:list --json`. */
|
|
679
|
+
export function pickSmokeRoutes(output) {
|
|
680
|
+
const routes = parseJsonLoose(output);
|
|
681
|
+
if (!Array.isArray(routes))
|
|
682
|
+
return [];
|
|
683
|
+
const uris = routes
|
|
684
|
+
.filter((r) => String(r?.method ?? '').split('|').includes('GET'))
|
|
685
|
+
.filter((r) => typeof r?.uri === 'string' && !r.uri.includes('{') && !/^(_|telescope|horizon|pulse|livewire|sanctum|storage)/.test(r.uri))
|
|
686
|
+
.filter((r) => !(Array.isArray(r?.middleware) ? r.middleware : []).some((m) => /^(auth|verified|signed|can:|password\.confirm)/.test(String(m).split('\\').pop() ?? '') || /Authenticate/.test(String(m))))
|
|
687
|
+
.map((r) => String(r.uri));
|
|
688
|
+
return [...new Set(uris)].slice(0, MAX_SMOKE_ROUTES);
|
|
689
|
+
}
|
|
690
|
+
/** URI → action (mis. App\Http\Controllers\ProdukController@index) dari `route:list --json`. */
|
|
691
|
+
export function routeActions(output) {
|
|
692
|
+
const routes = parseJsonLoose(output);
|
|
693
|
+
const actions = new Map();
|
|
694
|
+
for (const r of Array.isArray(routes) ? routes : []) {
|
|
695
|
+
if (typeof r?.uri === 'string' && typeof r?.action === 'string' && r.action !== 'Closure' && !actions.has(r.uri))
|
|
696
|
+
actions.set(r.uri, r.action);
|
|
697
|
+
}
|
|
698
|
+
return actions;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Request FE (method + path, mis. GET /api/produk/12) → action route BE dari `route:list --json`.
|
|
702
|
+
* {param} cocok dengan satu segmen, {param?} opsional; bila beberapa cocok, route dengan segmen statis terbanyak menang.
|
|
703
|
+
*/
|
|
704
|
+
export function matchRoute(output, method, path) {
|
|
705
|
+
const routes = parseJsonLoose(output);
|
|
706
|
+
const target = path.split(/[?#]/)[0].replace(/^\/+|\/+$/g, '');
|
|
707
|
+
let best = null;
|
|
708
|
+
for (const r of Array.isArray(routes) ? routes : []) {
|
|
709
|
+
if (typeof r?.uri !== 'string' || typeof r?.action !== 'string' || r.action === 'Closure')
|
|
710
|
+
continue;
|
|
711
|
+
if (!String(r.method ?? '').split('|').includes(method.toUpperCase()))
|
|
712
|
+
continue;
|
|
713
|
+
const segments = r.uri.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
714
|
+
const pattern = segments
|
|
715
|
+
.map((s, i) => {
|
|
716
|
+
const sep = i ? '/' : '';
|
|
717
|
+
if (/^\{\w+\?\}$/.test(s))
|
|
718
|
+
return `(?:${sep}[^/]+)?`;
|
|
719
|
+
return sep + (/^\{\w+\}$/.test(s) ? '[^/]+' : s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
720
|
+
})
|
|
721
|
+
.join('');
|
|
722
|
+
if (!new RegExp(`^${pattern}$`).test(target))
|
|
723
|
+
continue;
|
|
724
|
+
const score = segments.filter((s) => !s.startsWith('{')).length;
|
|
725
|
+
if (score > (best?.score ?? -1))
|
|
726
|
+
best = { action: r.action, score };
|
|
727
|
+
}
|
|
728
|
+
return best?.action ?? null;
|
|
729
|
+
}
|
|
730
|
+
/** Action controller → berkas (PSR-4 App\ → app/) + baris method-nya; controller invokable → __invoke. */
|
|
731
|
+
export async function controllerLocation(root, action) {
|
|
732
|
+
const [cls, method = '__invoke'] = action.split('@');
|
|
733
|
+
if (!cls?.startsWith('App\\'))
|
|
734
|
+
return null;
|
|
735
|
+
const file = `app/${cls.slice(4).replace(/\\/g, '/')}.php`;
|
|
736
|
+
const source = await readFile(join(root, file), 'utf8').catch(() => null);
|
|
737
|
+
if (source === null)
|
|
738
|
+
return null;
|
|
739
|
+
const index = source.search(new RegExp(`function\\s+${method.replace(/\W/g, '')}\\s*\\(`));
|
|
740
|
+
return index < 0 ? { file } : { file, line: lineAt(source, index) };
|
|
741
|
+
}
|
|
742
|
+
const ROUTE_FILE = /(^|\/)(router|routes?)(\/|\.[jt]sx?$)|(^|\/)App\.(vue|[jt]sx)$/i;
|
|
743
|
+
const SOURCE_EXT = ['.vue', '.tsx', '.ts', '.jsx', '.js'];
|
|
744
|
+
/**
|
|
745
|
+
* Path halaman FE → komponen yang merender (best effort, tanpa menjalankan kode):
|
|
746
|
+
* 1. definisi route Vue Router / React Router (`path: '/produk'`, `path="/produk"`, termasuk :param dan child relatif)
|
|
747
|
+
* → komponen dari import('…') / import X from '…' di dekatnya, atau baris definisi route bila komponen tak ketemu;
|
|
748
|
+
* 2. routing berbasis berkas (pages/, app/ ala Nuxt / Next).
|
|
749
|
+
*/
|
|
750
|
+
export async function resolveRouteFile(root, path) {
|
|
751
|
+
const segments = path.split(/[?#]/)[0].split('/').filter(Boolean);
|
|
752
|
+
const files = (await listSources(join(root, 'src'), 4)).filter((f) => ROUTE_FILE.test(relative(root, f).replace(/\\/g, '/')));
|
|
753
|
+
let best = null;
|
|
754
|
+
for (const file of files) {
|
|
755
|
+
const source = await readFile(file, 'utf8').catch(() => '');
|
|
756
|
+
for (const m of source.matchAll(/\bpath\s*[:=]\s*\{?\s*(['"`])([^'"`]*)\1/g)) {
|
|
757
|
+
const score = routeScore(m[2], segments);
|
|
758
|
+
if (score > (best?.score ?? 0))
|
|
759
|
+
best = { file, source, index: m.index, score };
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (best) {
|
|
763
|
+
const component = await componentNear(root, best.file, best.source, best.index);
|
|
764
|
+
return component ?? { file: relative(root, best.file).replace(/\\/g, '/'), line: lineAt(best.source, best.index) };
|
|
765
|
+
}
|
|
766
|
+
for (const dir of ['src/pages', 'pages', 'src/app', 'app']) {
|
|
767
|
+
const base = join(root, dir, ...segments);
|
|
768
|
+
for (const candidate of SOURCE_EXT.flatMap((ext) => [`${base}${ext}`, join(base, `index${ext}`), join(base, `page${ext}`)])) {
|
|
769
|
+
if (await exists(candidate))
|
|
770
|
+
return { file: relative(root, candidate).replace(/\\/g, '/') };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
/** Kecocokan pola route dengan segmen path: 0 = tidak cocok; pola absolut penuh > child relatif; segmen statis > :param. */
|
|
776
|
+
function routeScore(pattern, segments) {
|
|
777
|
+
if (/[*(]/.test(pattern))
|
|
778
|
+
return 0;
|
|
779
|
+
const parts = pattern.split('/').filter(Boolean);
|
|
780
|
+
const absolute = pattern.startsWith('/');
|
|
781
|
+
if (!parts.length)
|
|
782
|
+
return absolute && !segments.length ? 100 : 0;
|
|
783
|
+
if (parts.length > segments.length || (absolute && parts.length !== segments.length))
|
|
784
|
+
return 0;
|
|
785
|
+
const tail = segments.slice(segments.length - parts.length);
|
|
786
|
+
if (!parts.every((p, i) => p.startsWith(':') || p === tail[i]))
|
|
787
|
+
return 0;
|
|
788
|
+
return (absolute ? 50 : 0) + parts.length * 2 + parts.filter((p) => !p.startsWith(':')).length;
|
|
789
|
+
}
|
|
790
|
+
/** Komponen route di definisi yang sama: dari awal objek `{` / elemen `<Route` sampai definisi path berikutnya. */
|
|
791
|
+
async function componentNear(root, file, source, index) {
|
|
792
|
+
const next = source.slice(index + 4).search(/\bpath\s*[:=]/);
|
|
793
|
+
const start = Math.max(0, source.lastIndexOf('{', index), source.lastIndexOf('<', index));
|
|
794
|
+
const block = source.slice(start, index + 4 + (next < 0 ? 600 : Math.min(next, 600)));
|
|
795
|
+
let spec = /import\(\s*(?:\/\*[\s\S]*?\*\/\s*)?['"]([^'"]+)['"]\s*\)/.exec(block)?.[1];
|
|
796
|
+
if (!spec) {
|
|
797
|
+
const name = /\b(?:component|Component|element)\s*[:=]\s*\{?\s*<?\s*([A-Z]\w*)/.exec(block)?.[1];
|
|
798
|
+
if (name)
|
|
799
|
+
spec = new RegExp(`import\\s+${name}\\s+from\\s+['"]([^'"]+)['"]`).exec(source)?.[1];
|
|
800
|
+
}
|
|
801
|
+
if (!spec || !(spec.startsWith('.') || spec.startsWith('@/') || spec.startsWith('~/')))
|
|
802
|
+
return null;
|
|
803
|
+
const base = spec.startsWith('.') ? join(dirname(file), spec) : join(root, 'src', spec.slice(2));
|
|
804
|
+
for (const candidate of [base, ...SOURCE_EXT.map((ext) => base + ext), ...SOURCE_EXT.map((ext) => join(base, `index${ext}`))]) {
|
|
805
|
+
if (await stat(candidate).then((s) => s.isFile(), () => false))
|
|
806
|
+
return { file: relative(root, candidate).replace(/\\/g, '/') };
|
|
807
|
+
}
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
async function listSources(dir, depth) {
|
|
811
|
+
if (depth < 0)
|
|
812
|
+
return [];
|
|
813
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
814
|
+
const out = [];
|
|
815
|
+
for (const entry of entries) {
|
|
816
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.'))
|
|
817
|
+
continue;
|
|
818
|
+
const full = join(dir, entry.name);
|
|
819
|
+
if (entry.isDirectory())
|
|
820
|
+
out.push(...(await listSources(full, depth - 1)));
|
|
821
|
+
else if (SOURCE_EXT.some((ext) => entry.name.endsWith(ext)))
|
|
822
|
+
out.push(full);
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
|
827
|
+
export function scoreRepo(findings, booted) {
|
|
828
|
+
const score = Math.max(0, 100 - findings.reduce((sum, f) => sum + (PENALTY[f.severity] ?? 0), 0));
|
|
829
|
+
return booted ? score : Math.min(score, 40);
|
|
830
|
+
}
|
|
831
|
+
/** Akun login dari catatan aplikasi, mis. "Login admin@pos.test / password". */
|
|
832
|
+
export function parseLogin(instructions) {
|
|
833
|
+
const match = /([\w.+-]+@[\w-]+(?:\.[\w-]+)+)\s*\/\s*(\S+)/.exec(instructions);
|
|
834
|
+
return match ? { email: match[1], password: match[2] } : null;
|
|
835
|
+
}
|
|
836
|
+
/** Variabel VITE_* berisi URL http diarahkan ke BE lokal; path (mis. /api) dipertahankan. */
|
|
837
|
+
export function localApiEnv(example) {
|
|
838
|
+
const lines = [];
|
|
839
|
+
for (const match of example.matchAll(/^(VITE_[A-Z0-9_]+)=["']?(https?:\/\/[^"'\s]*)["']?\s*$/gm)) {
|
|
840
|
+
const path = new URL(match[2]).pathname.replace(/\/$/, '');
|
|
841
|
+
lines.push(`${match[1]}=${BE_URL}${path}`);
|
|
842
|
+
}
|
|
843
|
+
return lines.join('\n') + (lines.length ? '\n' : '');
|
|
844
|
+
}
|
|
845
|
+
/** Ganti / tambah KEY=VALUE di isi file .env. */
|
|
846
|
+
export function setEnv(content, values) {
|
|
847
|
+
let out = content;
|
|
848
|
+
for (const [key, value] of Object.entries(values)) {
|
|
849
|
+
const line = `${key}=${value}`;
|
|
850
|
+
const pattern = new RegExp(`^#?\\s*${key}=.*$`, 'm');
|
|
851
|
+
out = pattern.test(out) ? out.replace(pattern, line) : `${out.replace(/\n?$/, '\n')}${line}\n`;
|
|
852
|
+
}
|
|
853
|
+
return out;
|
|
854
|
+
}
|
|
855
|
+
function summarize(label, result) {
|
|
856
|
+
const score = scoreRepo(result.findings, result.booted);
|
|
857
|
+
return {
|
|
858
|
+
score,
|
|
859
|
+
verdict: !result.booted ? `${label} gagal dijalankan` : score >= 80 ? `${label} sesuai` : `${label} perlu perbaikan`,
|
|
860
|
+
booted: result.booted,
|
|
861
|
+
notes: ['Audit tanpa AI.', ...result.notes].join(' '),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
function bootFailure(repo, title, description, output, file) {
|
|
865
|
+
return {
|
|
866
|
+
repo,
|
|
867
|
+
area: 'error_handling',
|
|
868
|
+
severity: 'critical',
|
|
869
|
+
title,
|
|
870
|
+
description,
|
|
871
|
+
file,
|
|
872
|
+
evidence: { response: tail(output) },
|
|
873
|
+
suggestion: 'Ikuti log di bukti; pastikan aplikasi bisa dipasang dan dijalankan dari README di lingkungan bersih.',
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const bySeverity = (a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
|
|
877
|
+
const tail = (text, max = 4000) => (text.length > max ? `…${text.slice(-max)}` : text);
|
|
878
|
+
const exists = (path) => stat(path).then(() => true, () => false);
|
|
879
|
+
function pathOf(href, origin) {
|
|
880
|
+
try {
|
|
881
|
+
const url = new URL(href, origin);
|
|
882
|
+
return url.origin === origin ? url.pathname + url.search : null;
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function pathnameOf(url) {
|
|
889
|
+
try {
|
|
890
|
+
return new URL(url).pathname;
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const stripOrigin = (url, origin) => (url.startsWith(origin) ? url.slice(origin.length) : url);
|
|
897
|
+
const slug = (path) => path.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 60) || 'root';
|
|
898
|
+
function attributes(raw) {
|
|
899
|
+
return Object.fromEntries([...raw.matchAll(/([\w:-]+)="([^"]*)"/g)].map((m) => [m[1], decode(m[2])]));
|
|
900
|
+
}
|
|
901
|
+
const decode = (text) => text
|
|
902
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
903
|
+
.replace(/</g, '<')
|
|
904
|
+
.replace(/>/g, '>')
|
|
905
|
+
.replace(/"/g, '"')
|
|
906
|
+
.replace(/�?39;|'/g, "'")
|
|
907
|
+
.replace(/ /g, '\n')
|
|
908
|
+
.replace(/&/g, '&');
|
|
909
|
+
function relativeTo(file, root) {
|
|
910
|
+
const normalized = file.replace(/\\/g, '/');
|
|
911
|
+
const base = root.replace(/\\/g, '/').replace(/\/$/, '');
|
|
912
|
+
return base && normalized.startsWith(`${base}/`) ? normalized.slice(base.length + 1) : normalized.replace(/^.*?\/(tests|app|src)\//, '$1/');
|
|
913
|
+
}
|
|
914
|
+
/** Keluaran composer/npm kadang diawali peringatan; ambil dari karakter JSON pertama. */
|
|
915
|
+
function parseJsonLoose(output) {
|
|
916
|
+
const start = output.search(/[[{]/);
|
|
917
|
+
if (start < 0)
|
|
918
|
+
return null;
|
|
919
|
+
try {
|
|
920
|
+
return JSON.parse(output.slice(start));
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
async function waitHttp(url, timeoutMs, doFetch) {
|
|
927
|
+
const deadline = Date.now() + timeoutMs;
|
|
928
|
+
while (Date.now() < deadline) {
|
|
929
|
+
const ok = await doFetch(url, { signal: AbortSignal.timeout(5_000) }).then(() => true, () => false);
|
|
930
|
+
if (ok)
|
|
931
|
+
return true;
|
|
932
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
933
|
+
}
|
|
934
|
+
return false;
|
|
935
|
+
}
|
|
936
|
+
// ---------------------------------------------------------------- implementasi nyata (runner Ubuntu)
|
|
937
|
+
export const defaultShell = {
|
|
938
|
+
run(cmd, { cwd, timeoutMs, env }) {
|
|
939
|
+
return new Promise((resolve) => {
|
|
940
|
+
const child = spawn('bash', ['-c', cmd], { cwd, env: { ...process.env, CI: 'true', ...env }, detached: true });
|
|
941
|
+
let output = '';
|
|
942
|
+
const collect = (chunk) => {
|
|
943
|
+
output = (output + chunk.toString()).slice(-200_000);
|
|
944
|
+
};
|
|
945
|
+
child.stdout.on('data', collect);
|
|
946
|
+
child.stderr.on('data', collect);
|
|
947
|
+
const timer = setTimeout(() => {
|
|
948
|
+
output += `\n[qaify] dihentikan setelah ${Math.round(timeoutMs / 1000)} detik`;
|
|
949
|
+
killGroup(child.pid);
|
|
950
|
+
}, timeoutMs);
|
|
951
|
+
child.on('error', (e) => {
|
|
952
|
+
clearTimeout(timer);
|
|
953
|
+
resolve({ code: 127, output: `${output}\n${e.message}` });
|
|
954
|
+
});
|
|
955
|
+
child.on('close', (code) => {
|
|
956
|
+
clearTimeout(timer);
|
|
957
|
+
resolve({ code: code ?? 1, output });
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
},
|
|
961
|
+
background(cmd, { cwd, logFile, env }) {
|
|
962
|
+
const fd = openSync(logFile, 'a');
|
|
963
|
+
const child = spawn('bash', ['-c', cmd], { cwd, env: { ...process.env, CI: 'true', ...env }, detached: true, stdio: ['ignore', fd, fd] });
|
|
964
|
+
child.unref();
|
|
965
|
+
return () => killGroup(child.pid);
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
function killGroup(pid) {
|
|
969
|
+
if (!pid)
|
|
970
|
+
return;
|
|
971
|
+
try {
|
|
972
|
+
process.kill(-pid, 'SIGKILL');
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
try {
|
|
976
|
+
process.kill(pid, 'SIGKILL');
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
// sudah berhenti
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/** playwright-core dipasang global di runner bersama qaify; Google Chrome sudah ada di ubuntu-latest. */
|
|
984
|
+
export async function defaultLaunch() {
|
|
985
|
+
const name = 'playwright-core';
|
|
986
|
+
try {
|
|
987
|
+
const { chromium } = await import(name);
|
|
988
|
+
return (await chromium.launch({ channel: 'chrome', headless: true }));
|
|
989
|
+
}
|
|
990
|
+
catch (e) {
|
|
991
|
+
console.log(`qa-factory: playwright-core tidak bisa dipakai (${e.message.split('\n')[0]})`);
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
}
|