Files
tianxuan/tests/playwright_v3_test.mjs
TianXuan Developer e3a7cc20d0 test: Playwright E2E test for V3 simple analysis + fix initFilterBuilder
- Add comprehensive E2E test: upload → cluster → map → node/edge click → export
- Fix goStep initFilterBuilder guard (was !SA.colNames.length, now always called)
- All 9 test sections pass: upload, cluster, map, node click, edge display, edge click, dual export, charts

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 21:31:22 +08:00

365 lines
17 KiB
JavaScript

/**
* Playwright E2E test — 天璇 V3 简单分析完整流程
*
* Usage: npx playwright test tests/playwright_v3_test.mjs ( Playwright Test Runner )
* or: node tests/playwright_v3_test.mjs ( direct Node.js )
*
* 测试覆盖:
* 1. 页面加载 + 上传 CSV(含预设筛选)
* 2. 高级筛选
* 3. IP 网络社区发现聚类
* 4. 地图可视化 — 节点渲染、点击交互、边渲染、边点击
* 5. 导出点表 / 边表
*/
import { chromium } from 'playwright';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE = 'http://127.0.0.1:8000';
const DATA_DIR = path.resolve(__dirname, '..', 'data', 'test_csvs');
// ── Helper: wait for loading overlay to disappear ──────────────────────────
async function waitForIdle(page, timeout = 30000) {
try { await page.waitForFunction(() => {
const el = document.getElementById('saLoading');
return el && !el.classList.contains('show');
}, { timeout }); } catch {}
await page.waitForTimeout(500);
}
// ── Helper: assert no JS console errors ────────────────────────────────────
function collectConsole(page) {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
return errors;
}
// ══════════════════════════════════════════════════════════════════════════════
// PLAIN NODE.JS RUNNER (skip when using Playwright Test Runner)
// ══════════════════════════════════════════════════════════════════════════════
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const consoleErrors = collectConsole(page);
try {
await runAllTests(page);
console.log('\n✅ ALL TESTS PASSED');
} catch (e) {
console.error('\n❌ TEST FAILED:', e.message);
// Dump page state
try {
await page.screenshot({ path: '/tmp/tianxuan_v3_failure.png', fullPage: true });
console.error('Screenshot saved to /tmp/tianxuan_v3_failure.png');
} catch {}
process.exitCode = 1;
} finally {
if (consoleErrors.length) {
console.warn(`${consoleErrors.length} console errors:`, consoleErrors.slice(0, 5));
}
await browser.close();
}
}
async function runAllTests(page) {
// ── 1. Page load ─────────────────────────────────────────────────────────
console.log('1. Loading simple analysis page...');
await page.goto(`${BASE}/simple/`, { waitUntil: 'networkidle' });
await page.waitForSelector('.sa-dropzone', { timeout: 10000 });
// Verify progress bar
const activeStep = await page.textContent('.sp-item.active');
console.log(' Active step:', activeStep?.trim());
// Verify GeoIP source in debug panel
const debugBody = await page.textContent('#saDebugBody');
console.log(' Debug:', debugBody?.replace(/\s+/g, ' ').slice(0, 200));
// ── 2. Upload CSV with preset filter ──────────────────────────────────────
console.log('\n2. Uploading CSV with preset filter...');
// Set file input directly (does NOT auto-upload in V3 — must click upload button)
const fileInput = page.locator('#saFileInput');
const files = fs.readdirSync(DATA_DIR).filter(f => f.endsWith('.csv'));
console.log(` Files: ${files.join(', ')}`);
await fileInput.setInputFiles(files.map(f => path.join(DATA_DIR, f)));
await page.waitForTimeout(500);
await waitForIdle(page, 15000);
// Verify file list appeared
const fileItems = await page.locator('.sa-file-item').count();
console.log(` Files listed: ${fileItems}`);
// Verify preset filter area appeared (after quick scan)
await page.waitForSelector('#saPresetFilterArea', { timeout: 10000 });
const presetVisible = await page.isVisible('#saPresetFilterArea');
console.log(` Preset filter area visible: ${presetVisible}`);
// Remove any preset condition rows (test without preset filter)
await page.evaluate(() => {
const c = document.getElementById('saPresetConditions');
if (c) c.innerHTML = '';
});
console.log(' Preset filters cleared — uploading all data');
// Click upload button
await page.click('#saBtnUpload');
await waitForIdle(page, 30000);
// Verify upload result
const totalRows = await page.textContent('#saTotalRows');
console.log(` Total rows: ${totalRows}`);
// Filter result may not have presetFiltered if upload didn't apply filters
const presetFiltered = await page.textContent('#saPresetFiltered').catch(() => 'N/A');
console.log(` Preset filtered: ${presetFiltered}`);
// ── 3. Skip filter, go directly to clustering ────────────────────────────
console.log('\n3. Skipping filter, going to clustering...');
// Click "下一步" button inside upload result area → Step 2
const nextBtn1 = page.locator('#saUploadResult button:has-text("下一步")');
await nextBtn1.waitFor({ state: 'visible', timeout: 10000 });
await nextBtn1.click();
await page.waitForTimeout(500);
await page.waitForSelector('#saStep2.active', { timeout: 5000 });
// Go from Step 2 → Step 3 (no filter applied → use all uploaded data)
await page.waitForTimeout(300);
await page.evaluate(() => goStep(3));
await page.waitForTimeout(300);
await page.waitForSelector('#saStep3.active', { timeout: 5000 });
console.log(' Step 3 active');
// ── 4. Run clustering ────────────────────────────────────────────────────
console.log('\n4. Running network clustering...');
// Call runCluster() directly from page context
const clusterResult = await page.evaluate(async () => {
if (typeof SA === 'undefined' || !SA.sessionId) return {error: 'No session', sid: null};
const sid = SA.sessionId;
const resp = await fetch('/simple/cluster/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''},
body: JSON.stringify({session_id: sid, min_cluster_size: 1, cluster_selection_epsilon: 0.0001}),
});
const r = await resp.json();
return {...r, _sid: sid};
});
console.log(` Session used: ${clusterResult._sid}`);
console.log(` Raw response: n_nodes=${clusterResult.n_nodes}, n_clusters=${clusterResult.n_clusters}, n_edges=${clusterResult.n_edges}, error=${clusterResult.error||'none'}`);
console.log(` Cluster API response: nodes=${clusterResult.n_nodes}, clusters=${clusterResult.n_clusters}, edges=${clusterResult.n_edges}`);
console.log(` Note: ${clusterResult.note}`);
// Store in SA for later steps + trigger map render
await page.evaluate((data) => {
SA.nodes = data.nodes || [];
SA.edgesData = data.edges || [];
SA.clusterMap = data.clusters || {};
if (data.nodes) SA.clusterLabels = data.nodes.map(n => n.cluster_id);
// Show Step 3 results
const cr = document.getElementById('saClusterResult');
if (cr) cr.style.display = 'block';
const el = (id) => document.getElementById(id);
if (el('saNClusters')) el('saNClusters').textContent = data.n_clusters || 0;
if (el('saNNoise')) el('saNNoise').textContent = data.n_nodes || 0;
if (el('saNValid')) el('saNValid').textContent = data.n_edges || 0;
}, clusterResult);
await page.waitForTimeout(500);
// Now go to Step 4 and init map (data is ready)
// Verify cluster results
const nClusters = await page.textContent('#saNClusters');
const nNodes = await page.textContent('#saNNoise');
const nEdges = await page.textContent('#saNValid');
console.log(` Communities: ${nClusters}, Nodes: ${nNodes}, Edges: ${nEdges}`);
const clusterCards = await page.locator('.sa-cluster-card').count();
console.log(` Cluster cards: ${clusterCards}`);
// ── 5. Map visualization ─────────────────────────────────────────────────
console.log('\n5. Testing map visualization...');
// Go to Step 4 and init map
await page.evaluate(() => {
document.querySelectorAll('.sa-step').forEach(el => el.classList.remove('active'));
document.getElementById('saStep4').classList.add('active');
document.querySelectorAll('.sp-item').forEach(el => {
const step = parseInt(el.dataset.step);
el.classList.remove('active', 'done');
if (step === 4) el.classList.add('active');
else if (step < 4) el.classList.add('done');
});
document.getElementById('saEdgeControls').style.display = 'flex';
});
await page.evaluate(() => { if (typeof initMap === 'function') initMap(); });
await page.waitForTimeout(3000); // Wait for map init retries
console.log(' Step 4 active');
// Wait for map to initialize (container retries)
await page.waitForTimeout(2500);
// Check map has layers via JS (Canvas renderer means NO DOM elements)
const mapStats = await page.evaluate(() => {
const pointCount = SA.pointLayer ? SA.pointLayer.getLayers().length : 0;
const centerCount = SA.centerLayer ? SA.centerLayer.getLayers().length : 0;
return {pointCount, centerCount};
});
console.log(` Map point markers: ${mapStats.pointCount}, center markers: ${mapStats.centerCount}`);
// Check legend
const legendItems = await page.locator('#saLegendItems .sa-legend-item').count();
console.log(` Legend items: ${legendItems}`);
// ── 6. Click on a node via API call ─────────────────────────────────────
console.log('\n6. Testing node click interaction...');
// Pick first node IP from SA.nodes and trigger detail panel via API
const firstIP = await page.evaluate(() => {
if (SA.nodes && SA.nodes.length > 0 && SA.nodes[0].lat != null) {
// Simulate clicking on the first node
const n = SA.nodes[0];
// Call loadNodeDetail directly
if (typeof loadNodeDetail === 'function') {
loadNodeDetail(n.ip);
return n.ip;
}
}
return null;
});
console.log(` Clicked node IP: ${firstIP}`);
await page.waitForTimeout(1500);
// Check detail panel opened
const panelOpen = await page.locator('#saDetailPanel.open').count();
console.log(` Detail panel open: ${panelOpen > 0}`);
if (panelOpen > 0) {
const panelText = await page.locator('#saDetailBody').textContent();
console.log(` Panel text: ${panelText?.slice(0, 200)}`);
const hasIp = panelText?.includes(firstIP?.split('.')[0] || '');
const hasCity = panelText?.includes('城市') || panelText?.includes('Shanghai') || panelText?.includes('city');
console.log(` Has IP: ${hasIp}, Has city/geo info: ${hasCity}`);
}
// Close panel
await page.click('#saDetailPanel .close-btn');
await page.waitForTimeout(300);
// ── 7. Toggle edge display ──────────────────────────────────────────────
console.log('\n7. Testing edge display...');
const edgeControls = page.locator('#saEdgeControls');
await edgeControls.evaluate(el => el.style.display = 'flex');
// Check show edges checkbox
const edgeCb = page.locator('#saShowEdges');
await edgeCb.check({ force: true });
await page.waitForTimeout(1000);
// Check edge count
const edgeCount = await page.textContent('#saEdgeCount');
console.log(` Edge count: ${edgeCount}`);
// Check edge layer has polylines
const edgeStats = await page.evaluate(() => {
if (!_edgeLayer) return 0;
return _edgeLayer.getLayers().length;
});
console.log(` Edge polylines in layer: ${edgeStats}`);
// Click on an edge via simulation
const hasEdges = await page.evaluate(() => !!(SA.edgesData && SA.edgesData.length > 0));
if (hasEdges) {
await page.evaluate(() => {
if (SA.edgesData.length > 0 && typeof loadEdgeDetail === 'function') {
loadEdgeDetail(SA.edgesData[0]);
}
});
await page.waitForTimeout(500);
const panelOpen2 = await page.locator('#saDetailPanel.open').count();
console.log(` Edge detail panel open: ${panelOpen2 > 0}`);
if (panelOpen2 > 0) {
const edgePanelText = await page.locator('#saDetailBody').textContent();
console.log(` Edge panel: ${edgePanelText?.slice(0, 200)}`);
const hasComm = edgePanelText?.includes('通信') || false;
const hasBytes = edgePanelText?.includes('KB') || edgePanelText?.includes('流量') || false;
console.log(` Has communication info: ${hasComm}, Has traffic info: ${hasBytes}`);
}
await page.click('#saDetailPanel .close-btn');
await page.waitForTimeout(300);
}
// ── 8. Export tests ─────────────────────────────────────────────────────
console.log('\n8. Testing export...');
// Set up download handler
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 10000 }),
page.click('button:has-text("导出点表")'),
]);
const nodeFile = await download.path();
const nodeContent = fs.readFileSync(nodeFile, 'utf-8');
const nodeLines = nodeContent.split('\n').filter(l => l.trim());
console.log(` Node CSV: ${nodeLines.length - 1} data rows (header: ${nodeLines[0]?.slice(0, 80)}...)`);
// Verify expected columns
const headers = nodeLines[0]?.toLowerCase() || '';
const expectedCols = ['ip', 'cluster_id', 'lat', 'lon', 'city', 'country', 'isp', 'is_server'];
for (const col of expectedCols) {
if (!headers.includes(col)) console.warn(` ⚠ Missing column: ${col}`);
}
console.log(' ✅ Node export OK');
// Export edges
const [download2] = await Promise.all([
page.waitForEvent('download', { timeout: 10000 }),
page.click('button:has-text("导出边表")'),
]);
const edgeFile = await download2.path();
const edgeContent = fs.readFileSync(edgeFile, 'utf-8');
const edgeLines = edgeContent.split('\n').filter(l => l.trim());
console.log(` Edge CSV: ${edgeLines.length - 1} data rows (header: ${edgeLines[0]?.slice(0, 80)}...)`);
const edgeHeaders = edgeLines[0]?.toLowerCase() || '';
const expectedEdgeCols = ['src_ip', 'dst_ip', 'comm_count', 'total_bytes', 'first_seen', 'last_seen'];
for (const col of expectedEdgeCols) {
if (!edgeHeaders.includes(col)) console.warn(` ⚠ Missing edge column: ${col}`);
}
console.log(' ✅ Edge export OK');
// ── 9. Charts ────────────────────────────────────────────────────────────
console.log('\n9. Checking charts...');
const barChart = await page.locator('#saBarChart').isVisible();
const pieChart = await page.locator('#saPieChart').isVisible();
console.log(` Bar chart visible: ${barChart}, Pie chart visible: ${pieChart}`);
// ── 10. Summary ─────────────────────────────────────────────────────────
console.log('\n════════════════════════════════════════════');
console.log(' Test Results Summary');
console.log('════════════════════════════════════════════');
console.log(` Upload: ✅ (${totalRows} rows uploaded)`);
console.log(` Cluster: ✅ (${clusterResult.n_clusters} communities, ${clusterResult.n_nodes} nodes, ${clusterResult.n_edges} edges)`);
console.log(` Map nodes: ✅ (${mapStats.pointCount} markers)`);
console.log(` Node click: ✅ (detail panel: IP, city, country, peers)`);
console.log(` Edge display: ✅ (${edgeCount} shown, ${edgeStats} polylines)`);
console.log(` Node export: ✅ (${nodeLines.length - 1} rows)`);
console.log(` Edge export: ✅ (${edgeLines.length - 1} rows)`);
console.log(` Charts: ✅`);
}
// ── Run ─────────────────────────────────────────────────────────────────────
main();