qaify-playwright-reporter 0.3.1 → 0.4.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/README.md +8 -2
- package/dist/audit-static.d.ts +104 -0
- package/dist/audit-static.js +597 -0
- package/dist/audit.js +2 -2
- 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
package/README.md
CHANGED
|
@@ -78,15 +78,21 @@ Catatan:
|
|
|
78
78
|
|
|
79
79
|
Untuk aplikasi yang repo FE dan BE-nya terpisah dan belum memasang Playwright, Qaify bisa mengaudit keduanya dari satu **repo runner**. Repo FE dan BE tidak diubah sama sekali.
|
|
80
80
|
|
|
81
|
-
1.
|
|
81
|
+
1. Pilih repo runner: repo kosong (mis. `qaify-runner`) atau repo aplikasinya sendiri. Jalankan `npx qaify-playwright-reporter init-runner` di dalamnya. Perintah ini menulis `.github/workflows/qaify-audit.yml`.
|
|
82
82
|
2. Pasang GitHub App Qaify di repo runner, repo FE, dan repo BE.
|
|
83
|
-
3. Di repo runner, isi secret `QA_FACTORY_TOKEN` (token reporter repo runner), variable `QA_FACTORY_URL
|
|
83
|
+
3. Di repo runner, isi secret `QA_FACTORY_TOKEN` (token reporter repo runner), dan variable `QA_FACTORY_URL`. Secret `CLAUDE_CODE_OAUTH_TOKEN` bersifat opsional.
|
|
84
|
+
|
|
85
|
+
- Tanpa secret itu, audit berjalan **tanpa AI** (`qaify audit-static`): build, test, `composer audit` / `npm audit`, route GET publik BE, dan smoke test Playwright FE (desktop + mobile, dengan screenshot).
|
|
86
|
+
- Dengan secret itu, Claude yang mengaudit. Hasilnya lebih dalam, termasuk logika bisnis dan kontrak API.
|
|
87
|
+
- Mode bisa dipaksa lewat variable `QA_FACTORY_AUDIT_MODE` (`static` / `claude`).
|
|
84
88
|
4. Di Qaify → **Aplikasi**, tambahkan aplikasi (repo FE, BE, runner), lalu klik **Audit sekarang**.
|
|
85
89
|
|
|
86
90
|
Alur di workflow:
|
|
87
91
|
|
|
88
92
|
```bash
|
|
89
93
|
qaify audit-context --audit <id> --dir "$WORK" # clone apps/fe + apps/be, lalu cabut token clone
|
|
94
|
+
qaify audit-static --dir "$WORK" --out claude.json # mode static: tanpa AI
|
|
95
|
+
# atau mode claude:
|
|
90
96
|
qaify audit-schema --out schema.json --instructions instructions.txt
|
|
91
97
|
claude -p … # Bash + Playwright MCP: jalankan BE/FE, jelajahi UI, tulis temuan
|
|
92
98
|
qaify audit-submit --audit <id> --results claude.json --evidence "$WORK/evidence" --context "$WORK/context.json"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { AuditFinding } from './audit.js';
|
|
2
|
+
/** usage.model untuk audit tanpa AI; dipakai backend/PDF untuk menampilkan mode audit. */
|
|
3
|
+
export declare const STATIC_MODEL = "qaify-static";
|
|
4
|
+
export interface ShellResult {
|
|
5
|
+
code: number;
|
|
6
|
+
output: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Shell {
|
|
9
|
+
run(cmd: string, opts: {
|
|
10
|
+
cwd: string;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
}): Promise<ShellResult>;
|
|
14
|
+
/** Proses latar (server dev); kembalian menghentikannya. */
|
|
15
|
+
background(cmd: string, opts: {
|
|
16
|
+
cwd: string;
|
|
17
|
+
logFile: string;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
}): () => void;
|
|
20
|
+
}
|
|
21
|
+
/** Subset Playwright yang dipakai smoke test (playwright-core di runner, palsu di test). */
|
|
22
|
+
export interface BrowserLike {
|
|
23
|
+
newContext(opts: {
|
|
24
|
+
viewport: {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
}): Promise<ContextLike>;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface ContextLike {
|
|
32
|
+
newPage(): Promise<PageLike>;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export interface PageLike {
|
|
36
|
+
on(event: 'console' | 'pageerror' | 'response', fn: (arg: any) => void): void;
|
|
37
|
+
goto(url: string, opts: {
|
|
38
|
+
waitUntil: 'networkidle';
|
|
39
|
+
timeout: number;
|
|
40
|
+
}): Promise<unknown>;
|
|
41
|
+
url(): string;
|
|
42
|
+
evaluate(expression: string): Promise<unknown>;
|
|
43
|
+
screenshot(opts: {
|
|
44
|
+
path: string;
|
|
45
|
+
}): Promise<unknown>;
|
|
46
|
+
locator(selector: string): {
|
|
47
|
+
count(): Promise<number>;
|
|
48
|
+
first(): {
|
|
49
|
+
fill(value: string): Promise<void>;
|
|
50
|
+
press(key: string): Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
waitForLoadState(state: 'networkidle', opts: {
|
|
54
|
+
timeout: number;
|
|
55
|
+
}): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
export interface StaticDeps {
|
|
58
|
+
shell: Shell;
|
|
59
|
+
fetch: typeof fetch;
|
|
60
|
+
/** null = playwright-core tidak tersedia; smoke FE dilewati. */
|
|
61
|
+
launch: () => Promise<BrowserLike | null>;
|
|
62
|
+
log: (msg: string) => void;
|
|
63
|
+
}
|
|
64
|
+
export interface StaticAudit {
|
|
65
|
+
summary: Record<'fe' | 'be', {
|
|
66
|
+
score: number;
|
|
67
|
+
verdict: string;
|
|
68
|
+
booted: boolean;
|
|
69
|
+
notes: string;
|
|
70
|
+
}>;
|
|
71
|
+
findings: AuditFinding[];
|
|
72
|
+
}
|
|
73
|
+
/** Folder kerja hasil audit-context: apps/fe, apps/be, context.json, evidence/. */
|
|
74
|
+
export declare function auditStatic(dir: string, deps: StaticDeps): Promise<StaticAudit>;
|
|
75
|
+
/** Bentuk keluaran `claude -p --output-format json` agar parseAuditOutput / audit-submit tidak berubah. */
|
|
76
|
+
export declare function toAuditOutput(audit: StaticAudit, durationMs: number): string;
|
|
77
|
+
/** Jelajah link same-origin (BFS) di desktop, lalu ulangi halaman yang sama di mobile. */
|
|
78
|
+
export declare function smokeUi(browser: BrowserLike, baseUrl: string, evidenceDir: string, login: Login | null, log?: (msg: string) => void): Promise<{
|
|
79
|
+
findings: AuditFinding[];
|
|
80
|
+
pages: number;
|
|
81
|
+
loggedIn: boolean;
|
|
82
|
+
}>;
|
|
83
|
+
/** Test gagal / error dari laporan JUnit PHPUnit / Pest. */
|
|
84
|
+
export declare function parseJunit(xml: string, root?: string): AuditFinding[];
|
|
85
|
+
/** `composer audit --format=json` → satu temuan per advisory. */
|
|
86
|
+
export declare function parseComposerAudit(output: string): AuditFinding[];
|
|
87
|
+
/** `npm audit --json` (npm 7+) → satu temuan per paket berseverity high/critical. */
|
|
88
|
+
export declare function parseNpmAudit(output: string): AuditFinding[];
|
|
89
|
+
/** URI route GET tanpa parameter dan tanpa middleware autentikasi dari `route:list --json`. */
|
|
90
|
+
export declare function pickSmokeRoutes(output: string): string[];
|
|
91
|
+
export declare function scoreRepo(findings: AuditFinding[], booted: boolean): number;
|
|
92
|
+
export interface Login {
|
|
93
|
+
email: string;
|
|
94
|
+
password: string;
|
|
95
|
+
}
|
|
96
|
+
/** Akun login dari catatan aplikasi, mis. "Login admin@pos.test / password". */
|
|
97
|
+
export declare function parseLogin(instructions: string): Login | null;
|
|
98
|
+
/** Variabel VITE_* berisi URL http diarahkan ke BE lokal; path (mis. /api) dipertahankan. */
|
|
99
|
+
export declare function localApiEnv(example: string): string;
|
|
100
|
+
/** Ganti / tambah KEY=VALUE di isi file .env. */
|
|
101
|
+
export declare function setEnv(content: string, values: Record<string, string>): string;
|
|
102
|
+
export declare const defaultShell: Shell;
|
|
103
|
+
/** playwright-core dipasang global di runner bersama qaify; Google Chrome sudah ada di ubuntu-latest. */
|
|
104
|
+
export declare function defaultLaunch(): Promise<BrowserLike | null>;
|
|
@@ -0,0 +1,597 @@
|
|
|
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.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { openSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
/** usage.model untuk audit tanpa AI; dipakai backend/PDF untuk menampilkan mode audit. */
|
|
12
|
+
export const STATIC_MODEL = 'qaify-static';
|
|
13
|
+
const MAX_FINDINGS = 40;
|
|
14
|
+
const MAX_SMOKE_PAGES = 15;
|
|
15
|
+
const MAX_SMOKE_ROUTES = 40;
|
|
16
|
+
const BE_URL = 'http://127.0.0.1:8000';
|
|
17
|
+
const FE_URL = 'http://127.0.0.1:5173';
|
|
18
|
+
const VIEWPORTS = [
|
|
19
|
+
{ name: 'desktop', width: 1366, height: 768 },
|
|
20
|
+
{ name: 'mobile', width: 390, height: 844 },
|
|
21
|
+
];
|
|
22
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'];
|
|
23
|
+
const PENALTY = { critical: 25, high: 10, medium: 4, low: 1 };
|
|
24
|
+
const MIN = 60_000;
|
|
25
|
+
/** Folder kerja hasil audit-context: apps/fe, apps/be, context.json, evidence/. */
|
|
26
|
+
export async function auditStatic(dir, deps) {
|
|
27
|
+
await mkdir(join(dir, '.qaify'), { recursive: true });
|
|
28
|
+
await mkdir(join(dir, 'evidence'), { recursive: true });
|
|
29
|
+
const context = await readFile(join(dir, 'context.json'), 'utf8').then(JSON.parse, () => ({}));
|
|
30
|
+
const login = parseLogin(String(context?.project?.instructions ?? ''));
|
|
31
|
+
const be = await auditBe(join(dir, 'apps', 'be'), dir, deps);
|
|
32
|
+
let fe;
|
|
33
|
+
try {
|
|
34
|
+
fe = await auditFe(join(dir, 'apps', 'fe'), dir, deps, login);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
be.stop();
|
|
38
|
+
}
|
|
39
|
+
const findings = [...be.result.findings, ...fe.findings].sort(bySeverity).slice(0, MAX_FINDINGS);
|
|
40
|
+
return {
|
|
41
|
+
summary: { be: summarize('BE', be.result), fe: summarize('FE', fe) },
|
|
42
|
+
findings,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Bentuk keluaran `claude -p --output-format json` agar parseAuditOutput / audit-submit tidak berubah. */
|
|
46
|
+
export function toAuditOutput(audit, durationMs) {
|
|
47
|
+
return JSON.stringify({
|
|
48
|
+
type: 'result',
|
|
49
|
+
subtype: 'success',
|
|
50
|
+
duration_ms: durationMs,
|
|
51
|
+
structured_output: audit,
|
|
52
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
53
|
+
modelUsage: { [STATIC_MODEL]: { outputTokens: 0 } },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------- BE
|
|
57
|
+
async function auditBe(root, dir, deps) {
|
|
58
|
+
const result = { findings: [], booted: false, notes: [] };
|
|
59
|
+
const none = { result, stop: () => { } };
|
|
60
|
+
const { shell, log } = deps;
|
|
61
|
+
if (!(await exists(root))) {
|
|
62
|
+
result.notes.push('Repo BE tidak ada di folder kerja.');
|
|
63
|
+
return none;
|
|
64
|
+
}
|
|
65
|
+
if (await exists(join(root, 'composer.lock'))) {
|
|
66
|
+
log('qa-factory: [be] composer audit');
|
|
67
|
+
const audit = await shell.run('composer audit --locked --format=json --no-interaction', { cwd: root, timeoutMs: 3 * MIN });
|
|
68
|
+
result.findings.push(...parseComposerAudit(audit.output));
|
|
69
|
+
result.notes.push('composer audit dijalankan.');
|
|
70
|
+
}
|
|
71
|
+
if (!(await exists(join(root, 'artisan')))) {
|
|
72
|
+
result.notes.push('Bukan proyek Laravel (artisan tidak ada): BE tidak dijalankan.');
|
|
73
|
+
return none;
|
|
74
|
+
}
|
|
75
|
+
// .env ke database service runner (MySQL, atau PostgreSQL bila .env.example memakai pgsql)
|
|
76
|
+
const example = await readFile(join(root, '.env.example'), 'utf8').catch(() => '');
|
|
77
|
+
const pgsql = /^DB_CONNECTION=pgsql/m.test(example);
|
|
78
|
+
await writeFile(join(root, '.env'), setEnv(example, {
|
|
79
|
+
APP_ENV: 'local',
|
|
80
|
+
APP_DEBUG: 'true',
|
|
81
|
+
APP_URL: BE_URL,
|
|
82
|
+
DB_CONNECTION: pgsql ? 'pgsql' : 'mysql',
|
|
83
|
+
DB_HOST: '127.0.0.1',
|
|
84
|
+
DB_PORT: pgsql ? '5432' : '3306',
|
|
85
|
+
DB_DATABASE: 'app',
|
|
86
|
+
DB_USERNAME: pgsql ? 'postgres' : 'root',
|
|
87
|
+
DB_PASSWORD: pgsql ? 'postgres' : 'root',
|
|
88
|
+
}));
|
|
89
|
+
log('qa-factory: [be] composer install');
|
|
90
|
+
const install = await shell.run('composer install --no-interaction --no-progress --prefer-dist', { cwd: root, timeoutMs: 10 * MIN });
|
|
91
|
+
if (install.code !== 0) {
|
|
92
|
+
result.findings.push(bootFailure('be', 'composer install gagal', 'Dependensi BE tidak bisa dipasang.', install.output, 'composer.json'));
|
|
93
|
+
result.notes.push('composer install gagal: BE tidak dijalankan.');
|
|
94
|
+
return none;
|
|
95
|
+
}
|
|
96
|
+
log('qa-factory: [be] migrate --seed');
|
|
97
|
+
const migrate = await shell.run('php artisan key:generate --force --no-interaction && php artisan migrate --seed --force --no-interaction', {
|
|
98
|
+
cwd: root,
|
|
99
|
+
timeoutMs: 5 * MIN,
|
|
100
|
+
});
|
|
101
|
+
const migrated = migrate.code === 0;
|
|
102
|
+
if (!migrated) {
|
|
103
|
+
result.findings.push(bootFailure('be', 'Migrasi / seed database gagal', 'php artisan migrate --seed gagal di database kosong.', migrate.output, 'database/migrations'));
|
|
104
|
+
}
|
|
105
|
+
if (await exists(join(root, 'tests'))) {
|
|
106
|
+
log('qa-factory: [be] php artisan test');
|
|
107
|
+
const junit = join(dir, '.qaify', 'be-junit.xml');
|
|
108
|
+
const test = await shell.run(`php artisan test --log-junit "${junit}"`, { cwd: root, timeoutMs: 15 * MIN });
|
|
109
|
+
const xml = await readFile(junit, 'utf8').catch(() => '');
|
|
110
|
+
const failures = parseJunit(xml, root);
|
|
111
|
+
result.findings.push(...failures);
|
|
112
|
+
if (!xml && test.code !== 0) {
|
|
113
|
+
result.findings.push({
|
|
114
|
+
repo: 'be',
|
|
115
|
+
area: 'error_handling',
|
|
116
|
+
severity: 'medium',
|
|
117
|
+
title: 'Test BE tidak bisa dijalankan',
|
|
118
|
+
description: 'php artisan test berhenti sebelum menghasilkan laporan.',
|
|
119
|
+
evidence: { response: tail(test.output) },
|
|
120
|
+
suggestion: 'Jalankan php artisan test secara lokal dan perbaiki konfigurasi test (phpunit.xml, database test).',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
result.notes.push(xml ? `php artisan test: ${failures.length} test gagal.` : 'php artisan test tidak menghasilkan laporan.');
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
result.notes.push('Folder tests tidak ada: test BE dilewati.');
|
|
127
|
+
}
|
|
128
|
+
log('qa-factory: [be] php artisan serve');
|
|
129
|
+
const stop = shell.background(`php artisan serve --host=127.0.0.1 --port=8000`, { cwd: root, logFile: join(dir, '.qaify', 'be-serve.log') });
|
|
130
|
+
if (!(await waitHttp(BE_URL, 30_000, deps.fetch))) {
|
|
131
|
+
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(() => '')));
|
|
132
|
+
return { result, stop };
|
|
133
|
+
}
|
|
134
|
+
result.booted = migrated;
|
|
135
|
+
const routes = await shell.run('php artisan route:list --json --no-interaction', { cwd: root, timeoutMs: MIN });
|
|
136
|
+
const smoke = pickSmokeRoutes(routes.output);
|
|
137
|
+
for (const uri of smoke) {
|
|
138
|
+
const url = `${BE_URL}/${uri.replace(/^\//, '')}`;
|
|
139
|
+
const res = await deps.fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) }).catch(() => null);
|
|
140
|
+
if (res && res.status >= 500) {
|
|
141
|
+
result.findings.push({
|
|
142
|
+
repo: 'be',
|
|
143
|
+
area: 'error_handling',
|
|
144
|
+
severity: 'high',
|
|
145
|
+
title: `GET /${uri} membalas ${res.status}`,
|
|
146
|
+
description: `Route publik GET /${uri} membalas ${res.status} pada database hasil seed.`,
|
|
147
|
+
evidence: { request: `GET ${url}`, response: tail(await res.text().catch(() => ''), 2000) },
|
|
148
|
+
suggestion: 'Cek laravel.log untuk exception di route ini dan tangani kasus data kosong / parameter yang hilang.',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
result.notes.push(`Smoke API: ${smoke.length} route GET publik dipanggil.`);
|
|
153
|
+
return { result, stop };
|
|
154
|
+
}
|
|
155
|
+
// ---------------------------------------------------------------- FE
|
|
156
|
+
async function auditFe(root, dir, deps, login) {
|
|
157
|
+
const result = { findings: [], booted: false, notes: [] };
|
|
158
|
+
const { shell, log } = deps;
|
|
159
|
+
const pkg = await readFile(join(root, 'package.json'), 'utf8').then(JSON.parse, () => null);
|
|
160
|
+
if (!pkg) {
|
|
161
|
+
result.notes.push('package.json tidak ada: FE tidak dijalankan.');
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
const scripts = pkg.scripts ?? {};
|
|
165
|
+
const hasLock = await exists(join(root, 'package-lock.json'));
|
|
166
|
+
if (hasLock) {
|
|
167
|
+
log('qa-factory: [fe] npm audit');
|
|
168
|
+
const audit = await shell.run('npm audit --omit=dev --json --package-lock-only', { cwd: root, timeoutMs: 3 * MIN });
|
|
169
|
+
result.findings.push(...parseNpmAudit(audit.output));
|
|
170
|
+
result.notes.push('npm audit dijalankan.');
|
|
171
|
+
}
|
|
172
|
+
log('qa-factory: [fe] npm install');
|
|
173
|
+
const install = await shell.run(hasLock ? 'npm ci --no-audit --no-fund' : 'npm install --no-audit --no-fund', { cwd: root, timeoutMs: 10 * MIN });
|
|
174
|
+
if (install.code !== 0) {
|
|
175
|
+
result.findings.push(bootFailure('fe', 'Instalasi dependensi FE gagal', 'npm ci / npm install gagal.', install.output, 'package.json'));
|
|
176
|
+
result.notes.push('Instalasi dependensi gagal: FE tidak dijalankan.');
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
// API diarahkan ke BE lokal: setiap VITE_* berisi URL http di .env.example
|
|
180
|
+
const example = (await readFile(join(root, '.env.example'), 'utf8').catch(() => '')) || (await readFile(join(root, '.env'), 'utf8').catch(() => ''));
|
|
181
|
+
await writeFile(join(root, '.env.local'), localApiEnv(example));
|
|
182
|
+
if (scripts.build) {
|
|
183
|
+
log('qa-factory: [fe] npm run build');
|
|
184
|
+
const build = await shell.run('npm run build', { cwd: root, timeoutMs: 10 * MIN });
|
|
185
|
+
if (build.code !== 0) {
|
|
186
|
+
result.findings.push({
|
|
187
|
+
repo: 'fe',
|
|
188
|
+
area: 'ui',
|
|
189
|
+
severity: 'critical',
|
|
190
|
+
title: 'Build FE gagal',
|
|
191
|
+
description: 'npm run build gagal; aplikasi tidak bisa di-deploy dari commit ini.',
|
|
192
|
+
evidence: { response: tail(build.output) },
|
|
193
|
+
suggestion: 'Perbaiki error build / type-check pada log di atas.',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
result.notes.push(build.code === 0 ? 'Build berhasil.' : 'Build gagal.');
|
|
197
|
+
}
|
|
198
|
+
if (!scripts.dev) {
|
|
199
|
+
result.notes.push('Script dev tidak ada: smoke test UI dilewati.');
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
log('qa-factory: [fe] npm run dev');
|
|
203
|
+
const stop = shell.background('npm run dev -- --host 127.0.0.1 --port 5173 --strictPort', { cwd: root, logFile: join(dir, '.qaify', 'fe-dev.log') });
|
|
204
|
+
try {
|
|
205
|
+
if (!(await waitHttp(FE_URL, 60_000, deps.fetch))) {
|
|
206
|
+
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(() => '')));
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
result.booted = true;
|
|
210
|
+
const browser = await deps.launch().catch(() => null);
|
|
211
|
+
if (!browser) {
|
|
212
|
+
result.notes.push('Playwright tidak tersedia: smoke test UI dilewati.');
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const smoke = await smokeUi(browser, FE_URL, join(dir, 'evidence'), login, log);
|
|
217
|
+
result.findings.push(...smoke.findings);
|
|
218
|
+
result.notes.push(`Smoke UI: ${smoke.pages} halaman di desktop & mobile${login ? (smoke.loggedIn ? ', login berhasil' : ', login gagal') : ''}.`);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await browser.close().catch(() => { });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
stop();
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
/** Jelajah link same-origin (BFS) di desktop, lalu ulangi halaman yang sama di mobile. */
|
|
230
|
+
export async function smokeUi(browser, baseUrl, evidenceDir, login, log = () => { }) {
|
|
231
|
+
const origin = new URL(baseUrl).origin;
|
|
232
|
+
const issues = [];
|
|
233
|
+
const screenshots = new Map();
|
|
234
|
+
let paths = [];
|
|
235
|
+
let loggedIn = false;
|
|
236
|
+
for (const viewport of VIEWPORTS) {
|
|
237
|
+
const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height } });
|
|
238
|
+
try {
|
|
239
|
+
const page = await context.newPage();
|
|
240
|
+
let current = '/';
|
|
241
|
+
const push = (issue) => issues.push({ ...issue, path: current, viewport: viewport.name });
|
|
242
|
+
// "Failed to load resource" sudah tercatat lewat response (atau aset seperti favicon yang tidak penting)
|
|
243
|
+
page.on('console', (m) => m.type() === 'error' && !/^Failed to load resource/.test(String(m.text())) && push({ kind: 'script', message: String(m.text()) }));
|
|
244
|
+
page.on('pageerror', (e) => push({ kind: 'script', message: String(e?.message ?? e) }));
|
|
245
|
+
page.on('response', (r) => {
|
|
246
|
+
const type = r.request().resourceType();
|
|
247
|
+
if ((type === 'xhr' || type === 'fetch') && r.status() >= 400) {
|
|
248
|
+
push({ kind: 'response', status: r.status(), message: `${r.request().method()} ${stripOrigin(r.url(), origin)} → ${r.status()}` });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
const visit = async (path) => {
|
|
252
|
+
current = path;
|
|
253
|
+
try {
|
|
254
|
+
const res = (await page.goto(origin + path, { waitUntil: 'networkidle', timeout: 20_000 }));
|
|
255
|
+
const status = res?.status?.() ?? 200;
|
|
256
|
+
if (status >= 400) {
|
|
257
|
+
push({ kind: 'navigation', message: `Halaman membalas ${status}.` });
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
push({ kind: 'navigation', message: `Halaman gagal dimuat: ${e.message.split('\n')[0]}` });
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
await visit('/');
|
|
268
|
+
if (login && (await page.locator('input[type=password]').count()) > 0) {
|
|
269
|
+
await page.locator('input[type=email], input[name*=email i], input[name*=user i], input[type=text]').first().fill(login.email).catch(() => { });
|
|
270
|
+
const password = page.locator('input[type=password]').first();
|
|
271
|
+
await password.fill(login.password).catch(() => { });
|
|
272
|
+
await password.press('Enter').catch(() => { });
|
|
273
|
+
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => { });
|
|
274
|
+
loggedIn = (await page.locator('input[type=password]').count()) === 0;
|
|
275
|
+
}
|
|
276
|
+
const queue = viewport.name === 'desktop' ? [pathOf(page.url(), origin) ?? '/'] : [...paths];
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
while (queue.length && seen.size < MAX_SMOKE_PAGES) {
|
|
279
|
+
const path = queue.shift();
|
|
280
|
+
if (seen.has(path))
|
|
281
|
+
continue;
|
|
282
|
+
seen.add(path);
|
|
283
|
+
const before = issues.length;
|
|
284
|
+
if (!(await visit(path)))
|
|
285
|
+
continue;
|
|
286
|
+
const overflow = await page.evaluate('document.documentElement.scrollWidth > window.innerWidth + 1').catch(() => false);
|
|
287
|
+
if (overflow)
|
|
288
|
+
push({ kind: 'overflow', message: 'Konten meluap horizontal (muncul scroll ke samping).' });
|
|
289
|
+
if (viewport.name === 'desktop') {
|
|
290
|
+
const links = (await page.evaluate('[...document.querySelectorAll("a[href]")].map((a) => a.href)').catch(() => []));
|
|
291
|
+
for (const href of Array.isArray(links) ? links : []) {
|
|
292
|
+
const next = pathOf(String(href), origin);
|
|
293
|
+
if (next && !seen.has(next) && !/log-?out|sign-?out|keluar/i.test(next))
|
|
294
|
+
queue.push(next);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (issues.length > before) {
|
|
298
|
+
const file = `fe-${viewport.name}-${slug(path)}.png`;
|
|
299
|
+
await page
|
|
300
|
+
.screenshot({ path: join(evidenceDir, file) })
|
|
301
|
+
.then(() => screenshots.set(`${viewport.name} ${path}`, `evidence/${file}`))
|
|
302
|
+
.catch(() => { });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (viewport.name === 'desktop')
|
|
306
|
+
paths = [...seen];
|
|
307
|
+
log(`qa-factory: [fe] smoke ${viewport.name}: ${seen.size} halaman`);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
await context.close().catch(() => { });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { findings: issuesToFindings(issues, screenshots), pages: paths.length, loggedIn };
|
|
314
|
+
}
|
|
315
|
+
/** Satu temuan per (jenis, halaman); pesan sama di beberapa viewport digabung. */
|
|
316
|
+
function issuesToFindings(issues, screenshots) {
|
|
317
|
+
const groups = new Map();
|
|
318
|
+
for (const issue of issues) {
|
|
319
|
+
const key = `${issue.kind === 'response' && (issue.status ?? 0) >= 500 ? 'response5xx' : issue.kind} ${issue.path}`;
|
|
320
|
+
groups.set(key, [...(groups.get(key) ?? []), issue]);
|
|
321
|
+
}
|
|
322
|
+
return [...groups.values()].map((group) => {
|
|
323
|
+
const first = group[0];
|
|
324
|
+
const viewports = [...new Set(group.map((i) => i.viewport))];
|
|
325
|
+
const messages = [...new Set(group.map((i) => i.message))];
|
|
326
|
+
const screenshot = viewports.map((v) => screenshots.get(`${v} ${first.path}`)).find(Boolean);
|
|
327
|
+
const where = `${first.path} (${viewports.join(', ')})`;
|
|
328
|
+
const base = { repo: 'fe', evidence: screenshot ? { screenshot } : undefined };
|
|
329
|
+
const list = messages.slice(0, 5).map((m) => `- ${m}`).join('\n') + (messages.length > 5 ? `\n- … ${messages.length - 5} lainnya` : '');
|
|
330
|
+
switch (first.kind) {
|
|
331
|
+
case 'script':
|
|
332
|
+
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.' };
|
|
333
|
+
case 'response':
|
|
334
|
+
return {
|
|
335
|
+
...base,
|
|
336
|
+
area: 'api_contract',
|
|
337
|
+
severity: (first.status ?? 0) >= 500 ? 'high' : 'medium',
|
|
338
|
+
title: `Request API gagal di ${first.path}`,
|
|
339
|
+
description: `Request XHR/fetch gagal saat membuka ${where}:\n${list}`,
|
|
340
|
+
suggestion: 'Cocokkan endpoint, parameter, dan otorisasi yang dipanggil FE dengan route BE.',
|
|
341
|
+
};
|
|
342
|
+
case 'overflow':
|
|
343
|
+
return { ...base, area: 'ui', severity: 'medium', title: `Tampilan meluap horizontal di ${first.path}`, description: `Lebar konten melebihi layar di ${where}.`, suggestion: 'Bungkus tabel/elemen lebar dengan overflow-x-auto atau buat layout responsif.' };
|
|
344
|
+
default:
|
|
345
|
+
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.' };
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// ---------------------------------------------------------------- parser & helper (diuji terpisah)
|
|
350
|
+
/** Test gagal / error dari laporan JUnit PHPUnit / Pest. */
|
|
351
|
+
export function parseJunit(xml, root = '') {
|
|
352
|
+
const findings = [];
|
|
353
|
+
for (const match of xml.matchAll(/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g)) {
|
|
354
|
+
const body = match[2] ?? '';
|
|
355
|
+
const failure = /<(failure|error)\b[^>]*?(?:\/>|>([\s\S]*?)<\/\1>)/.exec(body);
|
|
356
|
+
if (!failure)
|
|
357
|
+
continue;
|
|
358
|
+
const attrs = attributes(match[1] ?? '');
|
|
359
|
+
const name = attrs.name ?? 'test';
|
|
360
|
+
const cls = attrs.class ?? attrs.classname ?? '';
|
|
361
|
+
const file = attrs.file ? relativeTo(attrs.file, root) : undefined;
|
|
362
|
+
const message = decode(failure[2] ?? '').trim();
|
|
363
|
+
findings.push({
|
|
364
|
+
repo: 'be',
|
|
365
|
+
area: 'business_logic',
|
|
366
|
+
severity: 'high',
|
|
367
|
+
title: `Test gagal: ${cls ? `${cls.split(/[\\.]/).pop()}::` : ''}${name}`.slice(0, 120),
|
|
368
|
+
description: message.slice(0, 4000) || 'Test gagal tanpa pesan.',
|
|
369
|
+
file,
|
|
370
|
+
line: attrs.line ? Number(attrs.line) : undefined,
|
|
371
|
+
suggestion: 'Jalankan test ini secara lokal; perbaiki kode atau perbarui test bila perilakunya memang berubah.',
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return findings;
|
|
375
|
+
}
|
|
376
|
+
/** `composer audit --format=json` → satu temuan per advisory. */
|
|
377
|
+
export function parseComposerAudit(output) {
|
|
378
|
+
const data = parseJsonLoose(output);
|
|
379
|
+
const advisories = data?.advisories && typeof data.advisories === 'object' ? data.advisories : {};
|
|
380
|
+
const findings = [];
|
|
381
|
+
for (const [pkg, list] of Object.entries(advisories)) {
|
|
382
|
+
for (const advisory of Array.isArray(list) ? list : Object.values(list ?? {})) {
|
|
383
|
+
const severity = SEVERITY_ORDER.includes(advisory?.severity) ? advisory.severity : 'high';
|
|
384
|
+
findings.push({
|
|
385
|
+
repo: 'be',
|
|
386
|
+
area: 'security',
|
|
387
|
+
severity,
|
|
388
|
+
title: `${pkg}: ${advisory?.title ?? 'kerentanan diketahui'}`.slice(0, 120),
|
|
389
|
+
description: [advisory?.title, advisory?.cve && `CVE: ${advisory.cve}`, advisory?.affectedVersions && `Versi terdampak: ${advisory.affectedVersions}`, advisory?.link]
|
|
390
|
+
.filter(Boolean)
|
|
391
|
+
.join('\n'),
|
|
392
|
+
file: 'composer.lock',
|
|
393
|
+
suggestion: `composer update ${pkg} ke versi yang sudah diperbaiki.`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return findings;
|
|
398
|
+
}
|
|
399
|
+
/** `npm audit --json` (npm 7+) → satu temuan per paket berseverity high/critical. */
|
|
400
|
+
export function parseNpmAudit(output) {
|
|
401
|
+
const data = parseJsonLoose(output);
|
|
402
|
+
const findings = [];
|
|
403
|
+
for (const [name, vuln] of Object.entries((data?.vulnerabilities ?? {}))) {
|
|
404
|
+
if (vuln?.severity !== 'high' && vuln?.severity !== 'critical')
|
|
405
|
+
continue;
|
|
406
|
+
const advisories = (Array.isArray(vuln.via) ? vuln.via : []).filter((v) => v && typeof v === 'object');
|
|
407
|
+
const fix = vuln.fixAvailable;
|
|
408
|
+
findings.push({
|
|
409
|
+
repo: 'fe',
|
|
410
|
+
area: 'security',
|
|
411
|
+
severity: vuln.severity,
|
|
412
|
+
title: `${name}: ${advisories[0]?.title ?? 'kerentanan diketahui'}`.slice(0, 120),
|
|
413
|
+
description: [
|
|
414
|
+
...advisories.slice(0, 3).map((a) => `${a.title}${a.url ? ` (${a.url})` : ''}`),
|
|
415
|
+
vuln.range && `Versi terdampak: ${vuln.range}`,
|
|
416
|
+
vuln.isDirect ? 'Dependensi langsung.' : 'Dependensi tidak langsung.',
|
|
417
|
+
]
|
|
418
|
+
.filter(Boolean)
|
|
419
|
+
.join('\n'),
|
|
420
|
+
file: 'package-lock.json',
|
|
421
|
+
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.',
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return findings;
|
|
425
|
+
}
|
|
426
|
+
/** URI route GET tanpa parameter dan tanpa middleware autentikasi dari `route:list --json`. */
|
|
427
|
+
export function pickSmokeRoutes(output) {
|
|
428
|
+
const routes = parseJsonLoose(output);
|
|
429
|
+
if (!Array.isArray(routes))
|
|
430
|
+
return [];
|
|
431
|
+
const uris = routes
|
|
432
|
+
.filter((r) => String(r?.method ?? '').split('|').includes('GET'))
|
|
433
|
+
.filter((r) => typeof r?.uri === 'string' && !r.uri.includes('{') && !/^(_|telescope|horizon|pulse|livewire|sanctum|storage)/.test(r.uri))
|
|
434
|
+
.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))))
|
|
435
|
+
.map((r) => String(r.uri));
|
|
436
|
+
return [...new Set(uris)].slice(0, MAX_SMOKE_ROUTES);
|
|
437
|
+
}
|
|
438
|
+
export function scoreRepo(findings, booted) {
|
|
439
|
+
const score = Math.max(0, 100 - findings.reduce((sum, f) => sum + (PENALTY[f.severity] ?? 0), 0));
|
|
440
|
+
return booted ? score : Math.min(score, 40);
|
|
441
|
+
}
|
|
442
|
+
/** Akun login dari catatan aplikasi, mis. "Login admin@pos.test / password". */
|
|
443
|
+
export function parseLogin(instructions) {
|
|
444
|
+
const match = /([\w.+-]+@[\w-]+(?:\.[\w-]+)+)\s*\/\s*(\S+)/.exec(instructions);
|
|
445
|
+
return match ? { email: match[1], password: match[2] } : null;
|
|
446
|
+
}
|
|
447
|
+
/** Variabel VITE_* berisi URL http diarahkan ke BE lokal; path (mis. /api) dipertahankan. */
|
|
448
|
+
export function localApiEnv(example) {
|
|
449
|
+
const lines = [];
|
|
450
|
+
for (const match of example.matchAll(/^(VITE_[A-Z0-9_]+)=["']?(https?:\/\/[^"'\s]*)["']?\s*$/gm)) {
|
|
451
|
+
const path = new URL(match[2]).pathname.replace(/\/$/, '');
|
|
452
|
+
lines.push(`${match[1]}=${BE_URL}${path}`);
|
|
453
|
+
}
|
|
454
|
+
return lines.join('\n') + (lines.length ? '\n' : '');
|
|
455
|
+
}
|
|
456
|
+
/** Ganti / tambah KEY=VALUE di isi file .env. */
|
|
457
|
+
export function setEnv(content, values) {
|
|
458
|
+
let out = content;
|
|
459
|
+
for (const [key, value] of Object.entries(values)) {
|
|
460
|
+
const line = `${key}=${value}`;
|
|
461
|
+
const pattern = new RegExp(`^#?\\s*${key}=.*$`, 'm');
|
|
462
|
+
out = pattern.test(out) ? out.replace(pattern, line) : `${out.replace(/\n?$/, '\n')}${line}\n`;
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
function summarize(label, result) {
|
|
467
|
+
const score = scoreRepo(result.findings, result.booted);
|
|
468
|
+
return {
|
|
469
|
+
score,
|
|
470
|
+
verdict: !result.booted ? `${label} gagal dijalankan` : score >= 80 ? `${label} sesuai` : `${label} perlu perbaikan`,
|
|
471
|
+
booted: result.booted,
|
|
472
|
+
notes: ['Audit tanpa AI.', ...result.notes].join(' '),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function bootFailure(repo, title, description, output, file) {
|
|
476
|
+
return {
|
|
477
|
+
repo,
|
|
478
|
+
area: 'error_handling',
|
|
479
|
+
severity: 'critical',
|
|
480
|
+
title,
|
|
481
|
+
description,
|
|
482
|
+
file,
|
|
483
|
+
evidence: { response: tail(output) },
|
|
484
|
+
suggestion: 'Ikuti log di bukti; pastikan aplikasi bisa dipasang dan dijalankan dari README di lingkungan bersih.',
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const bySeverity = (a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity);
|
|
488
|
+
const tail = (text, max = 4000) => (text.length > max ? `…${text.slice(-max)}` : text);
|
|
489
|
+
const exists = (path) => stat(path).then(() => true, () => false);
|
|
490
|
+
function pathOf(href, origin) {
|
|
491
|
+
try {
|
|
492
|
+
const url = new URL(href, origin);
|
|
493
|
+
return url.origin === origin ? url.pathname + url.search : null;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const stripOrigin = (url, origin) => (url.startsWith(origin) ? url.slice(origin.length) : url);
|
|
500
|
+
const slug = (path) => path.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 60) || 'root';
|
|
501
|
+
function attributes(raw) {
|
|
502
|
+
return Object.fromEntries([...raw.matchAll(/([\w:-]+)="([^"]*)"/g)].map((m) => [m[1], decode(m[2])]));
|
|
503
|
+
}
|
|
504
|
+
const decode = (text) => text
|
|
505
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
|
506
|
+
.replace(/</g, '<')
|
|
507
|
+
.replace(/>/g, '>')
|
|
508
|
+
.replace(/"/g, '"')
|
|
509
|
+
.replace(/�?39;|'/g, "'")
|
|
510
|
+
.replace(/ /g, '\n')
|
|
511
|
+
.replace(/&/g, '&');
|
|
512
|
+
function relativeTo(file, root) {
|
|
513
|
+
const normalized = file.replace(/\\/g, '/');
|
|
514
|
+
const base = root.replace(/\\/g, '/').replace(/\/$/, '');
|
|
515
|
+
return base && normalized.startsWith(`${base}/`) ? normalized.slice(base.length + 1) : normalized.replace(/^.*?\/(tests|app|src)\//, '$1/');
|
|
516
|
+
}
|
|
517
|
+
/** Keluaran composer/npm kadang diawali peringatan; ambil dari karakter JSON pertama. */
|
|
518
|
+
function parseJsonLoose(output) {
|
|
519
|
+
const start = output.search(/[[{]/);
|
|
520
|
+
if (start < 0)
|
|
521
|
+
return null;
|
|
522
|
+
try {
|
|
523
|
+
return JSON.parse(output.slice(start));
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function waitHttp(url, timeoutMs, doFetch) {
|
|
530
|
+
const deadline = Date.now() + timeoutMs;
|
|
531
|
+
while (Date.now() < deadline) {
|
|
532
|
+
const ok = await doFetch(url, { signal: AbortSignal.timeout(5_000) }).then(() => true, () => false);
|
|
533
|
+
if (ok)
|
|
534
|
+
return true;
|
|
535
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
536
|
+
}
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
// ---------------------------------------------------------------- implementasi nyata (runner Ubuntu)
|
|
540
|
+
export const defaultShell = {
|
|
541
|
+
run(cmd, { cwd, timeoutMs, env }) {
|
|
542
|
+
return new Promise((resolve) => {
|
|
543
|
+
const child = spawn('bash', ['-c', cmd], { cwd, env: { ...process.env, CI: 'true', ...env }, detached: true });
|
|
544
|
+
let output = '';
|
|
545
|
+
const collect = (chunk) => {
|
|
546
|
+
output = (output + chunk.toString()).slice(-200_000);
|
|
547
|
+
};
|
|
548
|
+
child.stdout.on('data', collect);
|
|
549
|
+
child.stderr.on('data', collect);
|
|
550
|
+
const timer = setTimeout(() => {
|
|
551
|
+
output += `\n[qaify] dihentikan setelah ${Math.round(timeoutMs / 1000)} detik`;
|
|
552
|
+
killGroup(child.pid);
|
|
553
|
+
}, timeoutMs);
|
|
554
|
+
child.on('error', (e) => {
|
|
555
|
+
clearTimeout(timer);
|
|
556
|
+
resolve({ code: 127, output: `${output}\n${e.message}` });
|
|
557
|
+
});
|
|
558
|
+
child.on('close', (code) => {
|
|
559
|
+
clearTimeout(timer);
|
|
560
|
+
resolve({ code: code ?? 1, output });
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
},
|
|
564
|
+
background(cmd, { cwd, logFile, env }) {
|
|
565
|
+
const fd = openSync(logFile, 'a');
|
|
566
|
+
const child = spawn('bash', ['-c', cmd], { cwd, env: { ...process.env, CI: 'true', ...env }, detached: true, stdio: ['ignore', fd, fd] });
|
|
567
|
+
child.unref();
|
|
568
|
+
return () => killGroup(child.pid);
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
function killGroup(pid) {
|
|
572
|
+
if (!pid)
|
|
573
|
+
return;
|
|
574
|
+
try {
|
|
575
|
+
process.kill(-pid, 'SIGKILL');
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
try {
|
|
579
|
+
process.kill(pid, 'SIGKILL');
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
// sudah berhenti
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** playwright-core dipasang global di runner bersama qaify; Google Chrome sudah ada di ubuntu-latest. */
|
|
587
|
+
export async function defaultLaunch() {
|
|
588
|
+
const name = 'playwright-core';
|
|
589
|
+
try {
|
|
590
|
+
const { chromium } = await import(name);
|
|
591
|
+
return (await chromium.launch({ channel: 'chrome', headless: true }));
|
|
592
|
+
}
|
|
593
|
+
catch (e) {
|
|
594
|
+
console.log(`qa-factory: playwright-core tidak bisa dipakai (${e.message.split('\n')[0]})`);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
}
|
package/dist/audit.js
CHANGED
|
@@ -196,10 +196,10 @@ export async function auditSubmit(client, auditId, claudeOutput, evidenceDir, co
|
|
|
196
196
|
const base = `/api/ingest/audits/${encodeURIComponent(auditId)}`;
|
|
197
197
|
const parsed = parseAuditOutput(claudeOutput);
|
|
198
198
|
if (!parsed) {
|
|
199
|
-
log('qa-factory:
|
|
199
|
+
log('qa-factory: auditor tidak menghasilkan hasil audit terstruktur');
|
|
200
200
|
return client.post(`${base}/results`, {
|
|
201
201
|
status: 'failed',
|
|
202
|
-
error: '
|
|
202
|
+
error: 'Auditor tidak menghasilkan hasil audit. Cek log step "Audit tanpa AI (Playwright)" atau "Audit dengan Claude Code" di repo runner; untuk mode Claude, cek juga secret CLAUDE_CODE_OAUTH_TOKEN dan kuota langganan.',
|
|
203
203
|
commits,
|
|
204
204
|
});
|
|
205
205
|
}
|
package/dist/cli.js
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
// qaify init-runner [--force] tulis qaify-audit.yml
|
|
12
12
|
// qaify audit-context --audit <id> --dir <folder kerja>
|
|
13
13
|
// qaify audit-schema --out schema.json --instructions instructions.txt | --prompt
|
|
14
|
+
// qaify audit-static --dir <folder kerja> --out claude.json (tanpa AI: build/test/audit + smoke Playwright)
|
|
14
15
|
// qaify audit-submit --audit <id> --results claude.json --evidence <folder> --context context.json
|
|
15
16
|
import { appendFile, readFile, writeFile } from 'node:fs/promises';
|
|
16
17
|
import { INSTRUCTIONS, RESULT_SCHEMA } from './ai.js';
|
|
17
18
|
import { aiQueue, aiSubmit, analyze, CONCLUSIONS, parseArgs } from './analyze.js';
|
|
18
19
|
import { AUDIT_INSTRUCTIONS, AUDIT_PROMPT, AUDIT_SCHEMA, auditContext, auditSubmit, readCommits } from './audit.js';
|
|
20
|
+
import { auditStatic, defaultLaunch, defaultShell, toAuditOutput } from './audit-static.js';
|
|
19
21
|
import { createClient, describeProblem, MISSING_CONFIG, runFromEnv, warn } from './client.js';
|
|
20
22
|
import { init, initRunner, NEXT_STEPS, RUNNER_NEXT_STEPS } from './init.js';
|
|
21
23
|
import { check } from './check.js';
|
|
@@ -29,6 +31,7 @@ const USAGE = `Pemakaian:
|
|
|
29
31
|
qaify init-runner [--force]
|
|
30
32
|
qaify audit-context --audit <id> --dir <folder>
|
|
31
33
|
qaify audit-schema (--out <file-schema> [--instructions <file>] | --prompt)
|
|
34
|
+
qaify audit-static --dir <folder> --out <file>
|
|
32
35
|
qaify audit-submit --audit <id> --results <file> --evidence <folder> --context <file>`;
|
|
33
36
|
const args = parseArgs(process.argv.slice(2));
|
|
34
37
|
const client = createClient();
|
|
@@ -132,6 +135,18 @@ switch (args.command) {
|
|
|
132
135
|
await writeFile(args.instructions, AUDIT_INSTRUCTIONS);
|
|
133
136
|
break;
|
|
134
137
|
}
|
|
138
|
+
// hanya membaca folder kerja; tidak memakai QA_FACTORY_TOKEN
|
|
139
|
+
case 'audit-static': {
|
|
140
|
+
if (!args.dir || !args.out) {
|
|
141
|
+
usage();
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
const started = Date.now();
|
|
145
|
+
const audit = await auditStatic(args.dir, { shell: defaultShell, fetch, launch: defaultLaunch, log: console.log });
|
|
146
|
+
await writeFile(args.out, toAuditOutput(audit, Date.now() - started));
|
|
147
|
+
console.log(`qa-factory: audit tanpa AI selesai, ${audit.findings.length} temuan (FE ${audit.summary.fe.score}, BE ${audit.summary.be.score})`);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
135
150
|
case 'audit-submit': {
|
|
136
151
|
if (!args.audit || !args.results || !args.evidence) {
|
|
137
152
|
usage();
|
|
@@ -141,7 +156,7 @@ switch (args.command) {
|
|
|
141
156
|
warn(`${MISSING_CONFIG} (audit-submit dilewati)`);
|
|
142
157
|
break;
|
|
143
158
|
}
|
|
144
|
-
// file tidak ada (
|
|
159
|
+
// file tidak ada (auditor dilewati / gagal) → audit ditandai gagal
|
|
145
160
|
const output = await readFile(args.results, 'utf8').catch(() => '');
|
|
146
161
|
const commits = args.context ? await readCommits(args.context) : {};
|
|
147
162
|
await auditSubmit(client, args.audit, output, args.evidence, commits);
|
package/dist/init.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare const SMOKE_PATH: string;
|
|
|
5
5
|
export declare const RUNNER_WORKFLOW_PATH: string;
|
|
6
6
|
export declare const REPORTER_SNIPPET = "reporter: [['list'], ['qaify-playwright-reporter']],\nuse: { trace: 'retain-on-failure', video: 'retain-on-failure', screenshot: 'only-on-failure' },";
|
|
7
7
|
export declare const NEXT_STEPS = "Langkah berikutnya:\n 1. GitHub \u2192 Settings \u2192 Secrets and variables \u2192 Actions:\n secret QA_FACTORY_TOKEN (Pengaturan \u2192 Token Reporter di QA Factory)\n variable QA_FACTORY_URL (URL API QA Factory, tab Variables)\n secret CLAUDE_CODE_OAUTH_TOKEN (opsional: `claude setup-token`, analisis AI dengan langganan Claude Anda)\n 2. Cek lokal (opsional): npx playwright install chromium && npx playwright test\n 3. Commit file di atas lalu push.";
|
|
8
|
-
export declare const RUNNER_NEXT_STEPS = "Langkah berikutnya (repo runner audit):\n 1. Pasang GitHub App Qaify di repo runner ini dan di repo FE + BE yang akan diaudit.\n 2. Qaify \u2192 Pengaturan \u2192 Token Reporter: buat token untuk repo runner ini.\n 3. GitHub \u2192 Settings \u2192 Secrets and variables \u2192 Actions:\n secret QA_FACTORY_TOKEN (token dari langkah 2)\n variable QA_FACTORY_URL (URL API Qaify, tab Variables)\n secret CLAUDE_CODE_OAUTH_TOKEN (`claude setup-token
|
|
8
|
+
export declare const RUNNER_NEXT_STEPS = "Langkah berikutnya (repo runner audit):\n 1. Pasang GitHub App Qaify di repo runner ini dan di repo FE + BE yang akan diaudit.\n 2. Qaify \u2192 Pengaturan \u2192 Token Reporter: buat token untuk repo runner ini.\n 3. GitHub \u2192 Settings \u2192 Secrets and variables \u2192 Actions:\n secret QA_FACTORY_TOKEN (token dari langkah 2)\n variable QA_FACTORY_URL (URL API Qaify, tab Variables)\n secret CLAUDE_CODE_OAUTH_TOKEN (opsional: audit dengan Claude, `claude setup-token`; tanpa ini audit berjalan tanpa AI)\n 4. Commit .github/workflows/qaify-audit.yml ke default branch lalu push.\n 5. Qaify \u2192 Aplikasi \u2192 Tambah aplikasi: pilih repo FE, BE, dan runner ini, lalu klik \"Audit sekarang\".";
|
|
9
9
|
export interface InitAction {
|
|
10
10
|
kind: 'created' | 'skipped' | 'hint';
|
|
11
11
|
path?: string;
|
package/dist/init.js
CHANGED
|
@@ -25,7 +25,7 @@ export const RUNNER_NEXT_STEPS = `Langkah berikutnya (repo runner audit):
|
|
|
25
25
|
3. GitHub → Settings → Secrets and variables → Actions:
|
|
26
26
|
secret QA_FACTORY_TOKEN (token dari langkah 2)
|
|
27
27
|
variable QA_FACTORY_URL (URL API Qaify, tab Variables)
|
|
28
|
-
secret CLAUDE_CODE_OAUTH_TOKEN (\`claude setup-token
|
|
28
|
+
secret CLAUDE_CODE_OAUTH_TOKEN (opsional: audit dengan Claude, \`claude setup-token\`; tanpa ini audit berjalan tanpa AI)
|
|
29
29
|
4. Commit .github/workflows/qaify-audit.yml ke default branch lalu push.
|
|
30
30
|
5. Qaify → Aplikasi → Tambah aplikasi: pilih repo FE, BE, dan runner ini, lalu klik "Audit sekarang".`;
|
|
31
31
|
const exists = (path) => access(path).then(() => true, () => false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qaify-playwright-reporter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Reporter Playwright untuk QA Factory (Qaify): kirim hasil test dan artefak ke /api/ingest, tanpa pernah menggagalkan test.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ahmdkenz",
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
# Butuh di repo runner ini (Settings → Secrets and variables → Actions):
|
|
4
4
|
# secret QA_FACTORY_TOKEN token reporter repo runner (Pengaturan → Token Reporter)
|
|
5
5
|
# variable QA_FACTORY_URL URL API Qaify
|
|
6
|
-
# secret CLAUDE_CODE_OAUTH_TOKEN langganan Claude Pro/Max Anda
|
|
6
|
+
# secret CLAUDE_CODE_OAUTH_TOKEN opsional: audit dengan Claude (langganan Claude Pro/Max Anda, `claude setup-token`)
|
|
7
|
+
# variable QA_FACTORY_AUDIT_MODE opsional, `static` atau `claude` (default: claude bila secret di atas ada)
|
|
7
8
|
# variable QA_FACTORY_AI_MODEL opsional, default sonnet
|
|
8
9
|
# variable QA_FACTORY_MCP_VERSION opsional, versi @playwright/mcp (default latest)
|
|
9
10
|
# GitHub App Qaify harus terpasang di repo ini dan di repo FE + BE. Nama file harus tetap qaify-audit.yml.
|
|
10
|
-
#
|
|
11
|
+
# Mode static (tanpa AI): build, test, composer/npm audit, dan smoke test Playwright; cepat, tanpa kuota AI,
|
|
12
|
+
# tetapi tanpa analisis logika bisnis / kontrak API dari kode.
|
|
13
|
+
# Mode claude: satu sesi Claude yang panjang; kuota langganan dipakai bersama pemakaian pribadi.
|
|
11
14
|
name: Qaify audit
|
|
12
15
|
run-name: audit ${{ inputs.audit_id }}
|
|
13
16
|
on:
|
|
@@ -41,7 +44,7 @@ jobs:
|
|
|
41
44
|
options: --health-cmd="pg_isready -U postgres" --health-interval=10s --health-timeout=5s --health-retries=10
|
|
42
45
|
# context runner tidak tersedia di env level job; WORK diisi lewat GITHUB_ENV di step pertama
|
|
43
46
|
env:
|
|
44
|
-
|
|
47
|
+
AUDIT_MODE: ${{ vars.QA_FACTORY_AUDIT_MODE || (secrets.CLAUDE_CODE_OAUTH_TOKEN != '' && 'claude' || 'static') }}
|
|
45
48
|
steps:
|
|
46
49
|
- name: Siapkan direktori kerja
|
|
47
50
|
run: echo "WORK=$RUNNER_TEMP/qaify" >> "$GITHUB_ENV"
|
|
@@ -54,10 +57,12 @@ jobs:
|
|
|
54
57
|
extensions: mbstring, intl, bcmath, gd, zip, pdo_mysql, pdo_pgsql
|
|
55
58
|
tools: composer
|
|
56
59
|
|
|
57
|
-
- name: Pasang Qaify CLI
|
|
58
|
-
run:
|
|
60
|
+
- name: Pasang Qaify CLI
|
|
61
|
+
run: |
|
|
62
|
+
npm install -g qaify-playwright-reporter@0.4 playwright-core@1
|
|
63
|
+
if [ "$AUDIT_MODE" = claude ]; then npm install -g @anthropic-ai/claude-code; fi
|
|
59
64
|
|
|
60
|
-
# token clone sekali pakai (read-only, hanya repo FE + BE) dicabut sebelum
|
|
65
|
+
# token clone sekali pakai (read-only, hanya repo FE + BE) dicabut sebelum auditor berjalan
|
|
61
66
|
- name: Ambil konteks & clone repo FE + BE
|
|
62
67
|
id: ctx
|
|
63
68
|
env:
|
|
@@ -65,8 +70,15 @@ jobs:
|
|
|
65
70
|
QA_FACTORY_TOKEN: ${{ secrets.QA_FACTORY_TOKEN }}
|
|
66
71
|
run: qaify audit-context --audit "${{ inputs.audit_id }}" --dir "$WORK"
|
|
67
72
|
|
|
73
|
+
# tanpa secret apa pun: kode aplikasi (composer/npm script) berjalan di step ini
|
|
74
|
+
- name: Audit tanpa AI (Playwright)
|
|
75
|
+
if: steps.ctx.outputs.ok == 'true' && env.AUDIT_MODE == 'static'
|
|
76
|
+
continue-on-error: true
|
|
77
|
+
timeout-minutes: 45
|
|
78
|
+
run: qaify audit-static --dir "$WORK" --out "$WORK/.qaify/claude.json"
|
|
79
|
+
|
|
68
80
|
- name: Siapkan instruksi & Playwright MCP
|
|
69
|
-
if: steps.ctx.outputs.ok == 'true'
|
|
81
|
+
if: steps.ctx.outputs.ok == 'true' && env.AUDIT_MODE == 'claude'
|
|
70
82
|
run: |
|
|
71
83
|
mkdir -p "$WORK/.qaify"
|
|
72
84
|
qaify audit-schema --out "$WORK/.qaify/schema.json" --instructions "$WORK/.qaify/instructions.txt"
|
|
@@ -77,7 +89,7 @@ jobs:
|
|
|
77
89
|
|
|
78
90
|
# hanya CLAUDE_CODE_OAUTH_TOKEN yang tersedia di step ini; QA_FACTORY_TOKEN tidak
|
|
79
91
|
- name: Audit dengan Claude Code (Claude Pro/Max)
|
|
80
|
-
if: steps.ctx.outputs.ok == 'true' && env.
|
|
92
|
+
if: steps.ctx.outputs.ok == 'true' && env.AUDIT_MODE == 'claude'
|
|
81
93
|
continue-on-error: true
|
|
82
94
|
timeout-minutes: 45
|
|
83
95
|
working-directory: ${{ runner.temp }}/qaify
|
|
@@ -94,7 +106,7 @@ jobs:
|
|
|
94
106
|
--output-format json --json-schema "$(cat .qaify/schema.json)" \
|
|
95
107
|
> .qaify/claude.json
|
|
96
108
|
|
|
97
|
-
# selalu jalan: tanpa hasil
|
|
109
|
+
# selalu jalan: tanpa hasil auditor, audit ditandai gagal beserta penyebab yang mungkin
|
|
98
110
|
- name: Kirim hasil audit
|
|
99
111
|
if: always() && steps.ctx.outputs.ok == 'true'
|
|
100
112
|
env:
|