20260718 重构 RPKI Explorer 查询界面
This commit is contained in:
@@ -1,62 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.setTimeout(120_000);
|
||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m7_copyable_values_pagination_playwright";
|
||||
|
||||
async function openRepositoryBranch(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||
await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||
await reposSection.locator(".stack-row-select").first().click();
|
||||
|
||||
const ppSection = page.locator('section[aria-label="Publication points"]');
|
||||
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
||||
await ppSection.locator(".stack-row-select").first().click();
|
||||
|
||||
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
||||
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
|
||||
return objectsPanel;
|
||||
}
|
||||
|
||||
test("long repository, publication point, URI, and hash values expose copy and full hover text", async ({ context, page }) => {
|
||||
await context.grantPermissions(["clipboard-write"], { origin: "http://127.0.0.1:5173" });
|
||||
const objectsPanel = await openRepositoryBranch(page);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Copy repository URI" }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy publication point URI" }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy manifest URI" }).first()).toBeVisible();
|
||||
await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible();
|
||||
await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible();
|
||||
|
||||
const objectUri = objectsPanel.locator(".uri-cell .copyable-value-text").first();
|
||||
const objectUriText = await objectUri.innerText();
|
||||
await objectUri.hover();
|
||||
await expect(page.locator(".copyable-tooltip")).toContainText(objectUriText);
|
||||
|
||||
await objectsPanel.getByRole("button", { name: "Copy object URI" }).first().click();
|
||||
await expect(objectsPanel.locator(".copy-state-text").first()).toHaveText("Copied");
|
||||
await objectUri.hover();
|
||||
await page.screenshot({ path: `${screenshotRoot}/repository-copyable-tooltip.png`, fullPage: true });
|
||||
});
|
||||
|
||||
test("object detail keeps copy controls without extra object list panels", async ({ page }) => {
|
||||
const objectsPanel = await openRepositoryBranch(page);
|
||||
await objectsPanel.getByRole("button", { name: "Open" }).first().click();
|
||||
await expect(page.getByText("Object detail · live query service")).toBeVisible({ timeout: 70_000 });
|
||||
await expect.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0);
|
||||
await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible();
|
||||
|
||||
const objectHash = page.locator(".object-meta-grid").getByText(/^[a-f0-9]{64}$/).first();
|
||||
const objectHashText = await objectHash.innerText();
|
||||
await objectHash.hover();
|
||||
await expect(page.locator(".copyable-tooltip")).toContainText(objectHashText.toLowerCase());
|
||||
await page.screenshot({ path: `${screenshotRoot}/object-detail-copyable-only.png`, fullPage: true });
|
||||
});
|
||||
321
ui/rpki-explorer/tests/e2e/fixtures.ts
Normal file
321
ui/rpki-explorer/tests/e2e/fixtures.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Deterministic fixture dataset for the mocked query service.
|
||||
* Object types use the report-stream vocabulary (manifest / certificate / aspa).
|
||||
*/
|
||||
|
||||
export const RUN_2 = {
|
||||
schemaVersion: 1,
|
||||
runId: "run_0002",
|
||||
runSeq: 2,
|
||||
validationTime: "2026-07-16T23:55:00Z",
|
||||
syncMode: "delta",
|
||||
wallMs: 732000,
|
||||
artifactPaths: {
|
||||
"report.json": "/data/runs/run_0002/report.json",
|
||||
"run-summary.json": "/data/runs/run_0002/run-summary.json",
|
||||
},
|
||||
counts: {
|
||||
publicationPoints: 4,
|
||||
objects: 1284,
|
||||
rejectedObjects: 2,
|
||||
warnings: 3,
|
||||
trustAnchors: 5,
|
||||
vrps: 987,
|
||||
aspas: 12,
|
||||
},
|
||||
indexStatus: "ready",
|
||||
};
|
||||
|
||||
export const RUN_1 = {
|
||||
...RUN_2,
|
||||
runId: "run_0001",
|
||||
runSeq: 1,
|
||||
validationTime: "2026-07-15T23:50:00Z",
|
||||
syncMode: "snapshot",
|
||||
wallMs: 1290000,
|
||||
artifactPaths: {
|
||||
"report.json": "/data/runs/run_0001/report.json",
|
||||
"run-summary.json": "/data/runs/run_0001/run-summary.json",
|
||||
},
|
||||
counts: { ...RUN_2.counts, objects: 1270, vrps: 981 },
|
||||
};
|
||||
|
||||
export const RUNS = [RUN_2, RUN_1];
|
||||
|
||||
export const REPO_A = {
|
||||
repoId: "repo-a1b2c3d4e5f6",
|
||||
uri: "rsync://repo-a.example/ca1/",
|
||||
host: "repo-a.example",
|
||||
transport: "rrdp",
|
||||
publicationPoints: 2,
|
||||
objects: 900,
|
||||
rejectedObjects: 1,
|
||||
downloadBytes: 245_000_000,
|
||||
syncDurationMsTotal: 12_400,
|
||||
phases: { rrdp_snapshot: 1, rrdp_delta: 1 },
|
||||
terminalStates: { fresh: 2 },
|
||||
};
|
||||
|
||||
export const REPO_B = {
|
||||
repoId: "repo-b2c3d4e5f6a7",
|
||||
uri: "rsync://repo-b.example/ca2/",
|
||||
host: "repo-b.example",
|
||||
transport: "rsync",
|
||||
publicationPoints: 1,
|
||||
objects: 300,
|
||||
rejectedObjects: 1,
|
||||
downloadBytes: 80_000_000,
|
||||
syncDurationMsTotal: 45_800,
|
||||
phases: { rsync: 1 },
|
||||
terminalStates: { cached: 1 },
|
||||
};
|
||||
|
||||
export const REPO_C = {
|
||||
repoId: "repo-c3d4e5f6a7b8",
|
||||
uri: "rsync://repo-c.example/ca3/",
|
||||
host: "repo-c.example",
|
||||
transport: "rrdp",
|
||||
publicationPoints: 1,
|
||||
objects: 84,
|
||||
rejectedObjects: 0,
|
||||
downloadBytes: 9_000_000,
|
||||
syncDurationMsTotal: 3_100,
|
||||
phases: { rrdp_delta: 1 },
|
||||
terminalStates: { fresh: 1 },
|
||||
};
|
||||
|
||||
export const REPOS = [REPO_A, REPO_B, REPO_C];
|
||||
|
||||
export const PP_1 = {
|
||||
ppId: "node_1",
|
||||
repoId: REPO_A.repoId,
|
||||
nodeId: 1,
|
||||
rsyncBaseUri: "rsync://repo-a.example/ca1/",
|
||||
manifestRsyncUri: "rsync://repo-a.example/ca1/manifest.mft",
|
||||
publicationPointRsyncUri: "rsync://repo-a.example/ca1/",
|
||||
rrdpNotificationUri: "https://repo-a.example/notification.xml",
|
||||
source: "rrdp",
|
||||
repoSyncSource: "rrdp",
|
||||
repoSyncPhase: "rrdp_delta",
|
||||
repoSyncDurationMs: 1200,
|
||||
repoSyncError: null,
|
||||
repoTerminalState: "fresh",
|
||||
thisUpdate: "2026-07-16T23:00:00Z",
|
||||
nextUpdate: "2026-07-17T23:00:00Z",
|
||||
objects: 610,
|
||||
rejectedObjects: 1,
|
||||
warnings: 2,
|
||||
};
|
||||
|
||||
export const PP_2 = {
|
||||
...PP_1,
|
||||
ppId: "node_2",
|
||||
nodeId: 2,
|
||||
manifestRsyncUri: "rsync://repo-a.example/ca1b/manifest.mft",
|
||||
publicationPointRsyncUri: "rsync://repo-a.example/ca1b/",
|
||||
objects: 290,
|
||||
rejectedObjects: 0,
|
||||
warnings: 1,
|
||||
};
|
||||
|
||||
export const PP_3 = {
|
||||
...PP_1,
|
||||
ppId: "node_3",
|
||||
nodeId: 3,
|
||||
repoId: REPO_B.repoId,
|
||||
manifestRsyncUri: "rsync://repo-b.example/ca2/manifest.mft",
|
||||
publicationPointRsyncUri: "rsync://repo-b.example/ca2/",
|
||||
rrdpNotificationUri: null,
|
||||
source: "rsync",
|
||||
repoSyncSource: "rsync",
|
||||
repoSyncPhase: "rsync",
|
||||
repoTerminalState: "cached",
|
||||
objects: 300,
|
||||
rejectedObjects: 1,
|
||||
warnings: 0,
|
||||
};
|
||||
|
||||
export const PPS = [PP_1, PP_2, PP_3];
|
||||
|
||||
const SHA = (ch: string) => ch.repeat(64);
|
||||
|
||||
export const OBJ_ROA = {
|
||||
objectInstanceId: "obj-roa-0001",
|
||||
uri: "rsync://repo-a.example/ca1/alice.roa",
|
||||
sha256: SHA("a"),
|
||||
objectType: "roa",
|
||||
result: "ok",
|
||||
detailSummary: null,
|
||||
repoId: REPO_A.repoId,
|
||||
ppId: PP_1.ppId,
|
||||
sourceSection: "fresh",
|
||||
rejected: false,
|
||||
rejectReason: null,
|
||||
};
|
||||
|
||||
export const OBJ_MFT = {
|
||||
objectInstanceId: "obj-mft-0001",
|
||||
uri: "rsync://repo-a.example/ca1/manifest.mft",
|
||||
sha256: SHA("b"),
|
||||
objectType: "manifest",
|
||||
result: "ok",
|
||||
detailSummary: null,
|
||||
repoId: REPO_A.repoId,
|
||||
ppId: PP_1.ppId,
|
||||
sourceSection: "fresh",
|
||||
rejected: false,
|
||||
rejectReason: null,
|
||||
};
|
||||
|
||||
export const OBJ_CER_REJECTED = {
|
||||
objectInstanceId: "obj-cer-0001",
|
||||
uri: "rsync://repo-a.example/ca1/stale.cer",
|
||||
sha256: SHA("c"),
|
||||
objectType: "certificate",
|
||||
result: "error",
|
||||
detailSummary: "certificate expired: notAfter 2026-01-01T00:00:00Z",
|
||||
repoId: REPO_A.repoId,
|
||||
ppId: PP_1.ppId,
|
||||
sourceSection: "cached",
|
||||
rejected: true,
|
||||
rejectReason: "certificate expired: notAfter 2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
export const OBJ_ASPA = {
|
||||
objectInstanceId: "obj-asa-0001",
|
||||
uri: "rsync://repo-b.example/ca2/bob.asa",
|
||||
sha256: SHA("d"),
|
||||
objectType: "aspa",
|
||||
result: "ok",
|
||||
detailSummary: null,
|
||||
repoId: REPO_B.repoId,
|
||||
ppId: PP_3.ppId,
|
||||
sourceSection: "cached",
|
||||
rejected: false,
|
||||
rejectReason: null,
|
||||
};
|
||||
|
||||
export const OBJ_ROA_REJECTED = {
|
||||
objectInstanceId: "obj-roa-0002",
|
||||
uri: "rsync://repo-b.example/ca2/broken.roa",
|
||||
sha256: SHA("e"),
|
||||
objectType: "roa",
|
||||
result: "error",
|
||||
detailSummary: "manifest stale: thisUpdate in the past",
|
||||
repoId: REPO_B.repoId,
|
||||
ppId: PP_3.ppId,
|
||||
sourceSection: "cached",
|
||||
rejected: true,
|
||||
rejectReason: "manifest stale: thisUpdate in the past",
|
||||
};
|
||||
|
||||
export const OBJECTS = [OBJ_ROA, OBJ_MFT, OBJ_CER_REJECTED, OBJ_ASPA, OBJ_ROA_REJECTED];
|
||||
|
||||
export const STATS_VALIDATION = { ok: 1279, error: 2, skipped: 3 };
|
||||
export const STATS_OBJECT_TYPES = {
|
||||
roa: 640,
|
||||
manifest: 4,
|
||||
crl: 4,
|
||||
certificate: 620,
|
||||
aspa: 12,
|
||||
gbr: 4,
|
||||
};
|
||||
export const STATS_REASONS = {
|
||||
"certificate expired: notAfter 2026-01-01T00:00:00Z": 1,
|
||||
"manifest stale: thisUpdate in the past": 1,
|
||||
};
|
||||
|
||||
export const ROA_PROJECTION = {
|
||||
schemaVersion: 1,
|
||||
sha256: SHA("a"),
|
||||
objectType: "roa",
|
||||
parseStatus: "ok",
|
||||
projection: {
|
||||
type: "roa",
|
||||
roa: {
|
||||
asId: 65001,
|
||||
ipAddressFamilies: [
|
||||
{
|
||||
afi: "ipv4",
|
||||
addresses: [
|
||||
{ prefix: "203.0.113.0/24", maxLength: 24 },
|
||||
{ prefix: "198.51.100.0/25", maxLength: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
afi: "ipv6",
|
||||
addresses: [{ prefix: "2001:db8::/32", maxLength: 48 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MFT_PROJECTION = {
|
||||
schemaVersion: 1,
|
||||
sha256: SHA("b"),
|
||||
objectType: "mft",
|
||||
parseStatus: "ok",
|
||||
projection: {
|
||||
type: "mft",
|
||||
manifest: {
|
||||
manifestNumberHex: "0A2F",
|
||||
thisUpdate: "2026-07-16T23:00:00Z",
|
||||
nextUpdate: "2026-07-17T23:00:00Z",
|
||||
fileHashAlg: "sha256",
|
||||
fileCount: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MFT_FILES = [
|
||||
{ fileName: "alice.roa", hashHex: SHA("a") },
|
||||
{ fileName: "manifest.mft", hashHex: SHA("b") },
|
||||
{ fileName: "stale.cer", hashHex: SHA("c") },
|
||||
];
|
||||
|
||||
export const VALIDATION_SUMMARY = {
|
||||
finalStatus: "valid",
|
||||
auditResult: "ok",
|
||||
detailSummary: null,
|
||||
parsevalidate: { status: "ok", issues: [] },
|
||||
chainvalidate: { status: "ok", issues: [] },
|
||||
};
|
||||
|
||||
export const EXPLAIN_RECORD = {
|
||||
finalStatus: "valid",
|
||||
auditResult: "ok",
|
||||
authoritative: false,
|
||||
explainMode: "audit_projection",
|
||||
parsevalidate: { status: "ok", issues: [] },
|
||||
chainvalidate: { status: "ok", mode: "audit_projection", issues: [], edgesCount: 2 },
|
||||
chainEdges: [],
|
||||
};
|
||||
|
||||
export const CHAIN_EDGES = [
|
||||
{
|
||||
relation: "issued_by",
|
||||
fromUri: "rsync://repo-a.example/ca1/alice.roa",
|
||||
toUri: "rsync://repo-a.example/ca1/ca-certificate.cer",
|
||||
toObjectInstanceId: "obj-cer-0002",
|
||||
toSha256: SHA("f"),
|
||||
status: "linked",
|
||||
},
|
||||
{
|
||||
relation: "listed_in",
|
||||
fromUri: "rsync://repo-a.example/ca1/alice.roa",
|
||||
toUri: "rsync://repo-a.example/ca1/manifest.mft",
|
||||
toObjectInstanceId: "obj-mft-0001",
|
||||
toSha256: SHA("b"),
|
||||
status: "linked",
|
||||
},
|
||||
];
|
||||
|
||||
export const HEALTH = {
|
||||
status: "ok",
|
||||
service: "rpki_query_service",
|
||||
version: 1,
|
||||
latestReadyRun: "run_0002",
|
||||
};
|
||||
|
||||
export const SERVICE_INFO = { service: "rpki_query_service", version: 1 };
|
||||
340
ui/rpki-explorer/tests/e2e/mockApi.ts
Normal file
340
ui/rpki-explorer/tests/e2e/mockApi.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Mocked rpki_query_service for Playwright: intercepts every /api/v1 request
|
||||
* and answers from the fixture dataset. Filter/query params are honored so
|
||||
* tests can assert both request construction and rendered results.
|
||||
*/
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
import {
|
||||
CHAIN_EDGES,
|
||||
EXPLAIN_RECORD,
|
||||
HEALTH,
|
||||
MFT_FILES,
|
||||
MFT_PROJECTION,
|
||||
OBJECTS,
|
||||
PPS,
|
||||
REPOS,
|
||||
ROA_PROJECTION,
|
||||
RUNS,
|
||||
RUN_2,
|
||||
SERVICE_INFO,
|
||||
STATS_OBJECT_TYPES,
|
||||
STATS_REASONS,
|
||||
STATS_VALIDATION,
|
||||
VALIDATION_SUMMARY,
|
||||
} from "./fixtures";
|
||||
|
||||
export interface MockJob {
|
||||
jobId: string;
|
||||
runId: string;
|
||||
scope: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
finishedAt: string | null;
|
||||
objectCount: number;
|
||||
bytesWritten: number;
|
||||
error: string | null;
|
||||
polls: number;
|
||||
}
|
||||
|
||||
export interface MockApi {
|
||||
jobs: Map<string, MockJob>;
|
||||
seedJob: (partial?: Partial<MockJob>) => MockJob;
|
||||
}
|
||||
|
||||
function envelope(data: unknown, nextCursor: string | null = null, limit = 100) {
|
||||
return {
|
||||
data,
|
||||
page: data === null || !Array.isArray(data) ? null : { nextCursor, limit },
|
||||
meta: { runId: "run_0002", schemaVersion: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function fulfillJson(route: Route, data: unknown, nextCursor: string | null = null, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data, nextCursor)),
|
||||
});
|
||||
}
|
||||
|
||||
function fulfillError(route: Route, status: number, message: string) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: message }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Offset-cursor pagination honoring ?limit / ?cursor. */
|
||||
function paginate<T>(items: T[], url: URL, defaultLimit = 50): { page: T[]; next: string | null } {
|
||||
const limit = Math.max(1, Number(url.searchParams.get("limit") ?? defaultLimit));
|
||||
const offset = Number(url.searchParams.get("cursor") ?? 0);
|
||||
const page = items.slice(offset, offset + limit);
|
||||
const nextOffset = offset + limit;
|
||||
return { page, next: nextOffset < items.length ? String(nextOffset) : null };
|
||||
}
|
||||
|
||||
function applyObjectFilters(items: typeof OBJECTS, url: URL): typeof OBJECTS {
|
||||
let out = items;
|
||||
const type = url.searchParams.get("type")?.toLowerCase();
|
||||
const result = url.searchParams.get("result")?.toLowerCase();
|
||||
const rejected = url.searchParams.get("rejected");
|
||||
const reason = url.searchParams.get("reason")?.toLowerCase();
|
||||
const q = url.searchParams.get("q")?.toLowerCase();
|
||||
if (type) {
|
||||
const alias: Record<string, string> = { mft: "manifest", cer: "certificate", asa: "aspa" };
|
||||
const wanted = alias[type] ?? type;
|
||||
out = out.filter((o) => o.objectType.toLowerCase() === wanted);
|
||||
}
|
||||
if (result) out = out.filter((o) => o.result.toLowerCase() === result);
|
||||
if (rejected === "true") out = out.filter((o) => o.rejected);
|
||||
if (rejected === "false") out = out.filter((o) => !o.rejected);
|
||||
if (reason) out = out.filter((o) => o.rejectReason?.toLowerCase().includes(reason));
|
||||
if (q) out = out.filter((o) => o.uri.toLowerCase().includes(q));
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function installMockApi(
|
||||
page: Page,
|
||||
options: { failLatestRun?: boolean } = {},
|
||||
): Promise<MockApi> {
|
||||
const jobs = new Map<string, MockJob>();
|
||||
let jobSeq = 0;
|
||||
|
||||
/** Simulate async completion: a running job completes on its second read. */
|
||||
const advanceJob = (job: MockJob) => {
|
||||
job.polls += 1;
|
||||
if (job.polls >= 2 && job.status === "running") {
|
||||
job.status = "complete";
|
||||
job.finishedAt = "2026-07-17T00:10:05Z";
|
||||
job.objectCount = 1;
|
||||
job.bytesWritten = 2048;
|
||||
}
|
||||
};
|
||||
|
||||
const seedJob = (partial: Partial<MockJob> = {}): MockJob => {
|
||||
jobSeq += 1;
|
||||
const job: MockJob = {
|
||||
jobId: `job-${String(jobSeq).padStart(4, "0")}`,
|
||||
runId: "run_0002",
|
||||
scope: "object_set",
|
||||
status: "running",
|
||||
createdAt: "2026-07-17T00:10:00Z",
|
||||
finishedAt: null,
|
||||
objectCount: 0,
|
||||
bytesWritten: 0,
|
||||
error: null,
|
||||
polls: 0,
|
||||
...partial,
|
||||
};
|
||||
jobs.set(job.jobId, job);
|
||||
return job;
|
||||
};
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname.replace(/^\/api\/v1\/?/, "");
|
||||
const segments = path.split("/").filter(Boolean).map(decodeURIComponent);
|
||||
|
||||
// Service-level
|
||||
if (segments.length === 0) return fulfillJson(route, SERVICE_INFO);
|
||||
if (path === "healthz") return fulfillJson(route, HEALTH);
|
||||
|
||||
if (segments[0] === "runs" || segments[0] === "latest_run") {
|
||||
const isLatestAlias = segments[0] === "latest_run";
|
||||
const rawRun = isLatestAlias ? "latest" : segments[1];
|
||||
const rest = isLatestAlias ? [] : segments.slice(2);
|
||||
|
||||
if (!isLatestAlias && segments.length === 1) {
|
||||
const { page: items, next } = paginate(RUNS, url);
|
||||
return fulfillJson(route, items, next);
|
||||
}
|
||||
|
||||
const run = rawRun === "latest" || rawRun === "latest_run" ? RUN_2 : RUNS.find((r) => r.runId === rawRun);
|
||||
if (!run) return fulfillError(route, 404, "run not found");
|
||||
|
||||
if (isLatestAlias || rest.length === 0) {
|
||||
if (options.failLatestRun) return fulfillError(route, 500, "index unavailable");
|
||||
return fulfillJson(route, run);
|
||||
}
|
||||
|
||||
const [section, a, b, c] = rest;
|
||||
|
||||
if (section === "artifacts") return fulfillJson(route, run.artifactPaths);
|
||||
if (section === "summary") {
|
||||
return fulfillJson(route, {
|
||||
publicationPoints: run.counts.publicationPoints,
|
||||
objects: run.counts.objects,
|
||||
repos: REPOS.length,
|
||||
vrps: run.counts.vrps,
|
||||
aspas: run.counts.aspas,
|
||||
});
|
||||
}
|
||||
|
||||
if (section === "repos" && !a) {
|
||||
const { page: items, next } = paginate(REPOS, url);
|
||||
return fulfillJson(route, items, next);
|
||||
}
|
||||
if (section === "repos" && a) {
|
||||
const repo = REPOS.find((r) => r.repoId === a);
|
||||
if (!repo) return fulfillError(route, 404, "repo not found");
|
||||
if (!b) return fulfillJson(route, repo);
|
||||
if (b === "stats") {
|
||||
return fulfillJson(route, {
|
||||
publicationPoints: repo.publicationPoints,
|
||||
objects: repo.objects,
|
||||
rejectedObjects: repo.rejectedObjects,
|
||||
syncDurationMsTotal: repo.syncDurationMsTotal,
|
||||
phases: repo.phases,
|
||||
terminalStates: repo.terminalStates,
|
||||
});
|
||||
}
|
||||
if (b === "publication-points") {
|
||||
const { page: items, next } = paginate(PPS.filter((p) => p.repoId === a), url);
|
||||
return fulfillJson(route, items, next);
|
||||
}
|
||||
if (b === "objects") {
|
||||
const filtered = applyObjectFilters(OBJECTS.filter((o) => o.repoId === a), url);
|
||||
const { page: items, next } = paginate(filtered, url);
|
||||
return fulfillJson(route, items, next);
|
||||
}
|
||||
}
|
||||
|
||||
if (section === "publication-points" && !a) {
|
||||
const { page: items, next } = paginate(PPS, url);
|
||||
return fulfillJson(route, items, next);
|
||||
}
|
||||
if (section === "publication-points" && a) {
|
||||
const pp = PPS.find((p) => p.ppId === a);
|
||||
if (!pp) return fulfillError(route, 404, "publication point not found");
|
||||
if (!b) return fulfillJson(route, pp);
|
||||
if (b === "stats") {
|
||||
return fulfillJson(route, {
|
||||
objects: pp.objects,
|
||||
rejectedObjects: pp.rejectedObjects,
|
||||
warnings: pp.warnings,
|
||||
repoSyncSource: pp.repoSyncSource,
|
||||
repoSyncPhase: pp.repoSyncPhase,
|
||||
repoSyncDurationMs: pp.repoSyncDurationMs,
|
||||
repoTerminalState: pp.repoTerminalState,
|
||||
});
|
||||
}
|
||||
if (b === "objects") {
|
||||
const filtered = applyObjectFilters(OBJECTS.filter((o) => o.ppId === a), url);
|
||||
const { page: items, next } = paginate(filtered, url);
|
||||
return fulfillJson(route, items, next);
|
||||
}
|
||||
}
|
||||
|
||||
if (section === "objects" && a === "by-uri") {
|
||||
const uri = url.searchParams.get("uri");
|
||||
const obj = OBJECTS.find((o) => o.uri === uri);
|
||||
if (!obj) return fulfillError(route, 404, "object not found");
|
||||
return fulfillJson(route, obj);
|
||||
}
|
||||
if (section === "objects" && !a) {
|
||||
const filtered = applyObjectFilters(OBJECTS, url);
|
||||
const { page: items, next } = paginate(filtered, url);
|
||||
return fulfillJson(route, items, next);
|
||||
}
|
||||
if (section === "objects" && a) {
|
||||
const obj = OBJECTS.find((o) => o.objectInstanceId === a);
|
||||
if (!obj) return fulfillError(route, 404, "object not found");
|
||||
if (!b) return fulfillJson(route, obj);
|
||||
if (b === "parsed" && !c) {
|
||||
if (obj.objectType === "roa") return fulfillJson(route, ROA_PROJECTION);
|
||||
if (obj.objectType === "manifest") return fulfillJson(route, MFT_PROJECTION);
|
||||
return fulfillJson(route, null);
|
||||
}
|
||||
if (b === "parsed" && c === "manifest-files") {
|
||||
const { page: items, next } = paginate(MFT_FILES, url, 100);
|
||||
return fulfillJson(route, items, next);
|
||||
}
|
||||
if (b === "parsed" && c === "revoked-certs") {
|
||||
return fulfillJson(route, [], null);
|
||||
}
|
||||
if (b === "validation" && !c) return fulfillJson(route, VALIDATION_SUMMARY);
|
||||
if (b === "validation" && c === "explain" && request.method() === "POST") {
|
||||
return fulfillJson(route, EXPLAIN_RECORD);
|
||||
}
|
||||
if (b === "chain") return fulfillJson(route, CHAIN_EDGES);
|
||||
if (b === "raw") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/octet-stream",
|
||||
body: Buffer.from([0x30, 0x82, 0x01, 0x01, 0x02, 0x01, 0x05]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (section === "issues") {
|
||||
let items = OBJECTS.filter((o) => o.rejected || o.result === "error");
|
||||
const reason = url.searchParams.get("reason")?.toLowerCase();
|
||||
const type = url.searchParams.get("type")?.toLowerCase();
|
||||
const repoId = url.searchParams.get("repoId");
|
||||
if (reason) items = items.filter((o) => o.rejectReason?.toLowerCase().includes(reason));
|
||||
if (type) items = items.filter((o) => o.objectType.toLowerCase() === type);
|
||||
if (repoId) items = items.filter((o) => o.repoId === repoId);
|
||||
const { page: pageItems, next } = paginate(items, url);
|
||||
return fulfillJson(route, pageItems, next);
|
||||
}
|
||||
|
||||
if (section === "search") {
|
||||
const q = (url.searchParams.get("q") ?? "").trim();
|
||||
if (!q) return fulfillError(route, 400, "q query parameter is required");
|
||||
const lower = q.toLowerCase();
|
||||
const byUri = OBJECTS.find((o) => o.uri === q);
|
||||
const byHash = OBJECTS.find((o) => o.sha256 === lower);
|
||||
const objects = byUri ? [byUri] : byHash ? [byHash] : [];
|
||||
const repos = REPOS.filter(
|
||||
(r) => r.host.toLowerCase().includes(lower) || r.uri.toLowerCase().includes(lower),
|
||||
);
|
||||
const pps = PPS.filter((p) =>
|
||||
[p.manifestRsyncUri, p.publicationPointRsyncUri, p.rrdpNotificationUri]
|
||||
.filter(Boolean)
|
||||
.some((u) => u!.toLowerCase().includes(lower)),
|
||||
);
|
||||
return fulfillJson(route, { objects, repos, publicationPoints: pps });
|
||||
}
|
||||
|
||||
if (section === "stats") {
|
||||
if (a === "validation") return fulfillJson(route, STATS_VALIDATION);
|
||||
if (a === "object-types") return fulfillJson(route, STATS_OBJECT_TYPES);
|
||||
if (a === "reasons") return fulfillJson(route, STATS_REASONS);
|
||||
if (a === "downloads") return fulfillJson(route, { requests: 42, bytes: 1234567 });
|
||||
return fulfillJson(route, {});
|
||||
}
|
||||
|
||||
if (section === "exports" && !a) {
|
||||
if (request.method() === "POST") {
|
||||
const job = seedJob({ objectCount: 0 });
|
||||
return fulfillJson(route, job);
|
||||
}
|
||||
const list = [...jobs.values()].sort((x, y) => y.createdAt.localeCompare(x.createdAt));
|
||||
list.forEach(advanceJob);
|
||||
return fulfillJson(route, list, null);
|
||||
}
|
||||
if (section === "exports" && a) {
|
||||
const job = jobs.get(a);
|
||||
if (!job) return fulfillError(route, 404, "export job not found");
|
||||
if (b === "download") {
|
||||
if (job.status !== "complete") return fulfillError(route, 409, "export not complete");
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/x-tar",
|
||||
body: Buffer.from("mock-tar-bytes"),
|
||||
});
|
||||
}
|
||||
advanceJob(job);
|
||||
return fulfillJson(route, job);
|
||||
}
|
||||
|
||||
return fulfillError(route, 404, `no mock for ${url.pathname}`);
|
||||
}
|
||||
|
||||
return fulfillError(route, 404, `no mock for ${url.pathname}`);
|
||||
});
|
||||
|
||||
return { jobs, seedJob };
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.setTimeout(120_000);
|
||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||
|
||||
async function openFirstRepositoryObject(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||
await reposSection.locator(".stack-row-select").first().click();
|
||||
await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click();
|
||||
const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first();
|
||||
await expect(row).toBeVisible({ timeout: 70_000 });
|
||||
const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim();
|
||||
await row.getByRole("button", { name: "Open" }).click();
|
||||
await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 });
|
||||
return expectedUri;
|
||||
}
|
||||
|
||||
test("renders live object detail and lazy tab requests", async ({ page }) => {
|
||||
const apiRequests: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.startsWith("/api/v1")) {
|
||||
apiRequests.push(`${url.pathname}${url.search}`);
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await openFirstRepositoryObject(page);
|
||||
await expect(page.getByText("Object detail · live query service")).toBeVisible();
|
||||
await expect(page.getByText("File and chain checks")).toBeVisible();
|
||||
const objectDetail = page.getByRole("complementary", { name: "Live object detail" });
|
||||
await expect(objectDetail.getByText("Authoritative", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0);
|
||||
await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible();
|
||||
expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(false);
|
||||
|
||||
await page.getByRole("tab", { name: "Parsed" }).click();
|
||||
await expect(page.getByText(/Projection JSON|Projection unavailable/)).toBeVisible({ timeout: 30_000 });
|
||||
expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(true);
|
||||
|
||||
await page.getByRole("tab", { name: "Chain" }).click();
|
||||
await expect(page.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "object-tab-chain");
|
||||
expect(apiRequests.some((request) => request.includes("/chain"))).toBe(true);
|
||||
|
||||
await page.getByRole("tab", { name: "Validation" }).click();
|
||||
await page.getByRole("button", { name: "Explain validation" }).click();
|
||||
await expect(page.getByText(/audit_projection|cached audit projection|Explain failed/)).toBeVisible({ timeout: 30_000 });
|
||||
expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true);
|
||||
|
||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-object-detail-live.png`, fullPage: true });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
88
ui/rpki-explorer/tests/e2e/object-detail.spec.ts
Normal file
88
ui/rpki-explorer/tests/e2e/object-detail.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockApi } from "./mockApi";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockApi(page);
|
||||
});
|
||||
|
||||
test("object detail renders header metadata and parsed ROA projection", async ({ page }) => {
|
||||
await page.goto("/objects/obj-roa-0001");
|
||||
|
||||
await expect(page.getByText("ROA object")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa").first()).toBeVisible();
|
||||
|
||||
// Parsed tab is the default and renders structured ROA content
|
||||
await expect(page.getByText("AS ID")).toBeVisible();
|
||||
await expect(page.getByText("65001")).toBeVisible();
|
||||
await expect(page.getByText("203.0.113.0/24")).toBeVisible();
|
||||
await expect(page.getByText("2001:db8::/32")).toBeVisible();
|
||||
});
|
||||
|
||||
test("tabs lazy-load: chain and validation requests fire only on activation", async ({ page }) => {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (req) => requests.push(req.url()));
|
||||
|
||||
await page.goto("/objects/obj-roa-0001");
|
||||
await expect(page.getByText("65001")).toBeVisible();
|
||||
expect(requests.some((u) => u.includes("/chain"))).toBe(false);
|
||||
expect(requests.some((u) => u.includes("/validation"))).toBe(false);
|
||||
|
||||
await page.getByRole("tab", { name: "Chain" }).click();
|
||||
await expect.poll(() => requests.some((u) => u.includes("/chain"))).toBe(true);
|
||||
await expect(page.getByText("issued_by")).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: "Validation" }).click();
|
||||
await expect.poll(() => requests.some((u) => u.includes("/validation"))).toBe(true);
|
||||
await expect(page.getByText("Final status")).toBeVisible();
|
||||
});
|
||||
|
||||
test("validation explain POSTs and renders the audit-projection notice", async ({ page }) => {
|
||||
await page.goto("/objects/obj-roa-0001?tab=validation");
|
||||
const posts: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
if (req.method() === "POST" && req.url().includes("/validation/explain")) posts.push(req.url());
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Run explain" }).click();
|
||||
await expect.poll(() => posts.length).toBe(1);
|
||||
await expect(page.getByText("audit_projection").first()).toBeVisible();
|
||||
await expect(page.getByText("not a full revalidation")).toBeVisible();
|
||||
});
|
||||
|
||||
test("manifest object renders the file list", async ({ page }) => {
|
||||
await page.goto("/objects/obj-mft-0001");
|
||||
await expect(page.getByText("Manifest number")).toBeVisible();
|
||||
await expect(page.getByText("0A2F")).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "alice.roa" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("object without projection shows the explicit unavailable state", async ({ page }) => {
|
||||
await page.goto("/objects/obj-asa-0001");
|
||||
await expect(page.getByText("Parsed projection unavailable")).toBeVisible();
|
||||
});
|
||||
|
||||
test("download raw triggers a browser download", async ({ page }) => {
|
||||
await page.goto("/objects/obj-roa-0001");
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download raw" }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe("alice.roa");
|
||||
});
|
||||
|
||||
test("export object set starts a job and offers the tarball when complete", async ({ page }) => {
|
||||
await page.goto("/objects/obj-roa-0001");
|
||||
const posts: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
if (req.method() === "POST" && req.url().includes("/exports")) posts.push(req.url());
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Export object set" }).click();
|
||||
await expect.poll(() => posts.length).toBe(1);
|
||||
// Mock completes the job after the first poll.
|
||||
await expect(page.getByRole("link", { name: "Download tar" })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("missing object shows an error state", async ({ page }) => {
|
||||
await page.goto("/objects/obj-does-not-exist");
|
||||
await expect(page.getByRole("alert")).toContainText("Failed to load object");
|
||||
});
|
||||
55
ui/rpki-explorer/tests/e2e/objects.spec.ts
Normal file
55
ui/rpki-explorer/tests/e2e/objects.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockApi } from "./mockApi";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockApi(page);
|
||||
});
|
||||
|
||||
test("objects page lists all objects with server-side filters", async ({ page }) => {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/objects?")) requests.push(req.url());
|
||||
});
|
||||
|
||||
await page.goto("/objects");
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeVisible();
|
||||
|
||||
// Type filter is sent to the backend and narrows the rendered list
|
||||
await page.getByLabel("Type").selectOption("manifest");
|
||||
await expect.poll(() => requests.some((url) => url.includes("type=manifest"))).toBe(true);
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/manifest.mft")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeHidden();
|
||||
|
||||
// Rejected-only checkbox
|
||||
await page.getByLabel("Type").selectOption("");
|
||||
await page.getByRole("checkbox", { name: "Rejected only" }).click();
|
||||
await expect(page.getByRole("checkbox", { name: "Rejected only" })).toBeChecked();
|
||||
await expect.poll(() => requests.some((url) => url.includes("rejected=true"))).toBe(true);
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden();
|
||||
|
||||
// URI substring filter
|
||||
await page.getByRole("checkbox", { name: "Rejected only" }).click();
|
||||
await expect(page.getByRole("checkbox", { name: "Rejected only" })).not.toBeChecked();
|
||||
await page.getByLabel("URI contains").fill("repo-b");
|
||||
await page.getByLabel("URI contains").press("Enter");
|
||||
await expect.poll(() => requests.some((url) => url.includes("q=repo-b"))).toBe(true);
|
||||
await expect(page.getByText("rsync://repo-b.example/ca2/bob.asa")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden();
|
||||
});
|
||||
|
||||
test("objects page deep link applies filters from the URL", async ({ page }) => {
|
||||
await page.goto("/objects?type=certificate&rejected=true");
|
||||
await expect(page.getByLabel("Type")).toHaveValue("certificate");
|
||||
await expect(page.getByRole("checkbox", { name: "Rejected only" })).toBeChecked();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeHidden();
|
||||
});
|
||||
|
||||
test("object row navigates to the object detail page", async ({ page }) => {
|
||||
await page.goto("/objects");
|
||||
await page.getByText("rsync://repo-a.example/ca1/alice.roa").click();
|
||||
await expect(page).toHaveURL(/\/objects\/obj-roa-0001/);
|
||||
await expect(page.getByText("ROA object")).toBeVisible();
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||
|
||||
test("renders live overview data from query service without global object list", async ({ page }) => {
|
||||
const apiRequests: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.startsWith("/api/v1")) {
|
||||
apiRequests.push(`${url.pathname}${url.search}`);
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
|
||||
await expect(page.getByText(/run_\d+/).first()).toBeVisible();
|
||||
await expect(page.getByText("Top repositories by workload")).toBeVisible();
|
||||
await expect(page.locator(".issue-card").first()).toBeVisible();
|
||||
|
||||
expect(apiRequests).toContain("/api/v1/latest_run");
|
||||
expect(apiRequests.some((request) => request.includes("/stats/object-types"))).toBe(true);
|
||||
expect(apiRequests.some((request) => request.includes("/stats/validation"))).toBe(true);
|
||||
expect(apiRequests.some((request) => request.includes("/stats/reasons"))).toBe(true);
|
||||
expect(apiRequests.some((request) => request.includes("/repos?limit=8"))).toBe(true);
|
||||
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
|
||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview-live.png`, fullPage: true });
|
||||
});
|
||||
63
ui/rpki-explorer/tests/e2e/overview.spec.ts
Normal file
63
ui/rpki-explorer/tests/e2e/overview.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockApi } from "./mockApi";
|
||||
|
||||
test("overview renders run strip, KPIs, charts, top repos and reasons", async ({ page }) => {
|
||||
await installMockApi(page);
|
||||
await page.goto("/");
|
||||
|
||||
// Run strip
|
||||
await expect(page.locator(".overview-run-strip").getByText("run_0002")).toBeVisible();
|
||||
await expect(page.getByText("delta sync")).toBeVisible();
|
||||
|
||||
// KPI cards with fixture values
|
||||
await expect(page.getByText("VRPs")).toBeVisible();
|
||||
await expect(page.getByText("987")).toBeVisible();
|
||||
await expect(page.locator(".kpi-grid").getByText("Rejected")).toBeVisible();
|
||||
|
||||
// Charts render (recharts svg)
|
||||
await expect(page.locator(".recharts-responsive-container").first()).toBeVisible();
|
||||
|
||||
// Top repositories table
|
||||
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible();
|
||||
|
||||
// Top reject reasons
|
||||
await expect(page.getByText("certificate expired: notAfter")).toBeVisible();
|
||||
});
|
||||
|
||||
test("overview does not request the global object list (first-screen discipline)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockApi(page);
|
||||
const objectListRequests: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
const url = new URL(req.url());
|
||||
if (/\/api\/v1\/runs\/[^/]+\/objects$/.test(url.pathname)) {
|
||||
objectListRequests.push(req.url());
|
||||
}
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("987")).toBeVisible();
|
||||
expect(objectListRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("overview drill-down: repo row navigates to repository detail", async ({ page }) => {
|
||||
await installMockApi(page);
|
||||
await page.goto("/");
|
||||
await page.getByRole("cell", { name: "repo-b.example" }).click();
|
||||
await expect(page).toHaveURL(/\/repositories\/repo-b2c3d4e5f6a7/);
|
||||
await expect(page.getByRole("heading", { name: "repo-b.example" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("overview drill-down: reason navigates to filtered validation page", async ({ page }) => {
|
||||
await installMockApi(page);
|
||||
await page.goto("/");
|
||||
await page.getByRole("link", { name: /manifest stale/ }).click();
|
||||
await expect(page).toHaveURL(/\/validation\?reason=manifest/);
|
||||
await expect(page.getByRole("heading", { name: "Validation" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("overview shows an explicit error state when the run cannot be loaded", async ({ page }) => {
|
||||
await installMockApi(page, { failLatestRun: true });
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("alert")).toContainText("Failed to load the latest run");
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.setTimeout(120_000);
|
||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||
|
||||
test("loads repository, publication point, and object data on demand", async ({ page }) => {
|
||||
const apiRequests: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.startsWith("/api/v1")) {
|
||||
apiRequests.push(`${url.pathname}${url.search}`);
|
||||
}
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
||||
await expect(page.getByText("Select a repository to load its publication points.")).toBeVisible();
|
||||
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(false);
|
||||
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
||||
|
||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||
await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(reposSection.getByLabel("Cursor pagination")).toContainText("Page 1");
|
||||
await expect(reposSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/.+ shown/);
|
||||
await reposSection.getByRole("button", { name: "Collapse repositories column" }).click();
|
||||
await expect(reposSection.locator(".collapsed-panel-summary")).toContainText("Repositories");
|
||||
await expect(reposSection.getByRole("button", { name: "Expand repositories column" })).toBeVisible();
|
||||
await reposSection.getByRole("button", { name: "Expand repositories column" }).click();
|
||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||
await expect(reposSection.locator(".stack-row")).toHaveCount(1);
|
||||
await reposSection.locator(".stack-row-select").first().click();
|
||||
|
||||
const ppSection = page.locator('section[aria-label="Publication points"]');
|
||||
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(ppSection.getByLabel("Cursor pagination")).toContainText("Page 1");
|
||||
await expect(ppSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
|
||||
await ppSection.getByRole("button", { name: "Collapse publication points column" }).click();
|
||||
await expect(ppSection.getByRole("button", { name: "Expand publication points column" })).toBeVisible();
|
||||
await ppSection.getByRole("button", { name: "Expand publication points column" }).click();
|
||||
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(true);
|
||||
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
||||
|
||||
await ppSection.locator(".stack-row-select").first().click();
|
||||
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
||||
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
|
||||
expect(apiRequests.some((request) => request.includes("/publication-points/") && request.includes("/objects?limit=50"))).toBe(true);
|
||||
await expect(objectsPanel.getByRole("button", { name: "Open" }).first()).toBeVisible();
|
||||
await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible();
|
||||
await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible();
|
||||
await expect(objectsPanel.getByLabel("Filter objects on current page")).toBeVisible();
|
||||
await expect(objectsPanel.getByLabel("Cursor pagination")).toBeVisible();
|
||||
await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText("Page 1");
|
||||
await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
|
||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-repository-browser.png`, fullPage: true });
|
||||
});
|
||||
|
||||
test("keeps selected repository and publication point after opening object detail", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||
|
||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||
await expect(reposSection.locator(".stack-row")).toHaveCount(1, { timeout: 30_000 });
|
||||
const selectedRepoUri = (await reposSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim();
|
||||
await reposSection.locator(".stack-row-select").first().click();
|
||||
|
||||
const ppSection = page.locator('section[aria-label="Publication points"]');
|
||||
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
||||
const selectedPpUri = (await ppSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim();
|
||||
await ppSection.locator(".stack-row-select").first().click();
|
||||
|
||||
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
||||
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
|
||||
const selectedObjectUri = (await objectsPanel.locator(".uri-cell .copyable-value-text").first().innerText()).trim();
|
||||
await objectsPanel.getByRole("button", { name: "Open" }).first().click();
|
||||
await expect(page.locator(".object-detail-card")).toContainText(selectedObjectUri, { timeout: 70_000 });
|
||||
|
||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
||||
await expect(reposSection.getByLabel("Filter repositories on current page")).toHaveValue("sakuya");
|
||||
await expect(reposSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedRepoUri);
|
||||
await expect(ppSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedPpUri);
|
||||
await expect(objectsPanel.locator("tbody tr").first()).toContainText(selectedObjectUri);
|
||||
});
|
||||
78
ui/rpki-explorer/tests/e2e/repositories.spec.ts
Normal file
78
ui/rpki-explorer/tests/e2e/repositories.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockApi } from "./mockApi";
|
||||
import { REPOS } from "./fixtures";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockApi(page);
|
||||
});
|
||||
|
||||
test("repository list renders rows and filters the current page", async ({ page }) => {
|
||||
await page.goto("/repositories");
|
||||
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "repo-b.example" })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "repo-c.example" })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Filter current page").fill("repo-b");
|
||||
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeHidden();
|
||||
await expect(page.getByRole("cell", { name: "repo-b.example" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("repository list paginates with cursors", async ({ page }) => {
|
||||
// Register a 2-page dataset on top of the base mock (later routes win).
|
||||
await page.route("**/api/v1/runs/*/repos?**", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
const items = cursor === "2" ? REPOS.slice(2) : REPOS.slice(0, 2);
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: items,
|
||||
page: { nextCursor: cursor === "2" ? null : "2", limit: 50 },
|
||||
meta: { runId: "run_0002", schemaVersion: 1 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/repositories");
|
||||
await expect(page.getByText("Page 1")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
await expect(page.getByText("Page 2")).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "repo-c.example" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Previous" }).click();
|
||||
await expect(page.getByText("Page 1")).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "repo-a.example" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("repository detail: tabs switch between PPs and objects", async ({ page }) => {
|
||||
await page.goto("/repositories/repo-a1b2c3d4e5f6");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "repo-a.example" })).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/manifest.mft")).toBeVisible();
|
||||
|
||||
// Objects tab is lazy: no repo-scoped objects request before activation
|
||||
const objectRequests: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/repos/repo-a1b2c3d4e5f6/objects")) objectRequests.push(req.url());
|
||||
});
|
||||
|
||||
await page.getByRole("tab", { name: "Objects" }).click();
|
||||
await expect(page.getByLabel("URI contains")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/alice.roa")).toBeVisible();
|
||||
expect(objectRequests.length).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole("tab", { name: "Publication points" }).click();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1b/manifest.mft")).toBeVisible();
|
||||
});
|
||||
|
||||
test("publication point detail shows metadata and its objects", async ({ page }) => {
|
||||
await page.goto("/publication-points/node_3");
|
||||
await expect(page.getByText("rsync://repo-b.example/ca2/manifest.mft").first()).toBeVisible();
|
||||
await expect(page.getByText("terminal cached")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible();
|
||||
});
|
||||
|
||||
test("publication points list navigates to detail", async ({ page }) => {
|
||||
await page.goto("/publication-points");
|
||||
await page.getByText("rsync://repo-b.example/ca2/manifest.mft").click();
|
||||
await expect(page).toHaveURL(/\/publication-points\/node_3/);
|
||||
});
|
||||
@@ -1,83 +1,101 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockApi } from "./mockApi";
|
||||
|
||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||
|
||||
function collectConsoleErrors(page: import("@playwright/test").Page) {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
const text = message.text();
|
||||
if (!text.includes("Failed to load resource")) {
|
||||
consoleErrors.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
return consoleErrors;
|
||||
}
|
||||
|
||||
test("opens the RPKI Explorer overview", async ({ page }) => {
|
||||
const consoleErrors = collectConsoleErrors(page);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Overview/i })).toBeVisible();
|
||||
await expect(page.getByLabel("Current run")).toContainText("latest indexed run");
|
||||
await expect(page.getByPlaceholder("Exact URI lookup planned in M2")).toBeDisabled();
|
||||
await expect(page.getByText("Top repositories by workload")).toBeVisible();
|
||||
await expect(page.getByText("Recent validation issues")).toBeVisible();
|
||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview.png`, fullPage: true });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockApi(page);
|
||||
});
|
||||
|
||||
test("collapses sidebar to icon-only navigation on desktop", async ({ page }) => {
|
||||
const consoleErrors = collectConsoleErrors(page);
|
||||
|
||||
test("shell renders navigation, brand and connection status", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
||||
await expect(page.getByText("RPKI Explorer")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Collapse navigation" }).click();
|
||||
await expect(page.locator(".app-shell")).toHaveClass(/sidebar-collapsed/);
|
||||
await expect(page.getByRole("button", { name: "Expand navigation" })).toBeVisible();
|
||||
await expect(page.locator(".brand-copy")).toHaveCSS("position", "absolute");
|
||||
await expect(page.locator(".nav-item").first().locator("span")).toHaveCSS("position", "absolute");
|
||||
await expect(page.getByRole("button", { name: "Repositories" })).toBeVisible();
|
||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-sidebar-collapsed.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Repositories" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Expand navigation" }).click();
|
||||
await expect(page.locator(".app-shell")).not.toHaveClass(/sidebar-collapsed/);
|
||||
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("opens truthful placeholders for unfinished navigation", async ({ page }) => {
|
||||
const consoleErrors = collectConsoleErrors(page);
|
||||
await page.goto("/");
|
||||
|
||||
for (const name of ["Publication Points", "Validation", "Exports", "Runs", "API"]) {
|
||||
await page.getByRole("button", { name }).click();
|
||||
await expect(page.locator("#coming-soon-heading")).toHaveText(name);
|
||||
await expect(page.getByText("Coming soon", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toHaveCount(0);
|
||||
const nav = page.getByRole("navigation", { name: "Primary" });
|
||||
for (const label of [
|
||||
"Overview",
|
||||
"Repositories",
|
||||
"Publication Points",
|
||||
"Objects",
|
||||
"Validation",
|
||||
"Exports",
|
||||
"Runs",
|
||||
"API",
|
||||
]) {
|
||||
await expect(nav.getByRole("link", { name: label })).toBeVisible();
|
||||
}
|
||||
|
||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-coming-soon.png`, fullPage: true });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
await expect(page.getByText("connected")).toBeVisible();
|
||||
});
|
||||
|
||||
test("objects page starts from explicit empty state", async ({ page }) => {
|
||||
const consoleErrors = collectConsoleErrors(page);
|
||||
test("every nav entry navigates to a real page (no dead controls)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const nav = page.getByRole("navigation", { name: "Primary" });
|
||||
|
||||
const cases: { label: string; path: RegExp; heading: string | RegExp }[] = [
|
||||
{ label: "Repositories", path: /\/repositories$/, heading: "Repositories" },
|
||||
{ label: "Publication Points", path: /\/publication-points$/, heading: "Publication Points" },
|
||||
{ label: "Objects", path: /\/objects$/, heading: "Objects" },
|
||||
{ label: "Validation", path: /\/validation$/, heading: "Validation" },
|
||||
{ label: "Exports", path: /\/exports$/, heading: "Exports" },
|
||||
{ label: "Runs", path: /\/runs$/, heading: "Runs" },
|
||||
{ label: "API", path: /\/api$/, heading: "API" },
|
||||
];
|
||||
|
||||
for (const { label, path, heading } of cases) {
|
||||
await nav.getByRole("link", { name: label, exact: true }).click();
|
||||
await expect(page).toHaveURL(path);
|
||||
await expect(page.getByRole("heading", { name: heading }).first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("run selector pins the run via ?run= and pages use it", async ({ page }) => {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/api/v1/runs/")) requests.push(req.url());
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /Objects/i }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Object object" })).toBeVisible();
|
||||
await expect(page.getByText("Search an exact URI or open an object from Repository Browser.")).toBeVisible();
|
||||
await expect(page.locator("body")).not.toContainText("sample PP");
|
||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-objects-empty.png`, fullPage: true });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
const selector = page.getByLabel("Run");
|
||||
await expect(selector).toBeVisible();
|
||||
await selector.selectOption("run_0001");
|
||||
await expect(page).toHaveURL(/run=run_0001/);
|
||||
await expect.poll(() => requests.some((url) => url.includes("/runs/run_0001/"))).toBe(true);
|
||||
});
|
||||
|
||||
test("topbar search routes to the search page with the query", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const input = page.getByRole("searchbox", { name: "Global search" });
|
||||
await input.fill("repo-a.example");
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(/\/search\?q=repo-a/);
|
||||
await expect(page.getByRole("heading", { name: "Search", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("unknown routes render the 404 page", async ({ page }) => {
|
||||
await page.goto("/definitely-not-a-page");
|
||||
await expect(page.getByText("404")).toBeVisible();
|
||||
await page.getByRole("link", { name: "Back to Overview" }).click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
|
||||
test("deep links restore page state after reload", async ({ page }) => {
|
||||
await page.goto("/objects?type=manifest");
|
||||
await expect(page).toHaveURL(/type=manifest/);
|
||||
await expect(page.getByLabel("Type")).toHaveValue("manifest");
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Type")).toHaveValue("manifest");
|
||||
});
|
||||
|
||||
test("runs page lists runs, pins a run and expands artifacts", async ({ page }) => {
|
||||
await page.goto("/runs");
|
||||
await expect(page.getByRole("cell", { name: "run_0002" })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "run_0001" })).toBeVisible();
|
||||
|
||||
// Expand artifacts for run_0001
|
||||
const row = page.getByRole("row", { name: /run_0001/ });
|
||||
await row.getByRole("button", { name: "Artifacts" }).click();
|
||||
await expect(page.getByText("/data/runs/run_0001/report.json")).toBeVisible();
|
||||
|
||||
// Pin run_0001 — navigates to overview with ?run=run_0001
|
||||
await row.getByRole("button", { name: "Use run" }).click();
|
||||
await expect(page).toHaveURL(/run=run_0001/);
|
||||
});
|
||||
|
||||
70
ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts
Normal file
70
ui/rpki-explorer/tests/e2e/validation-search-exports.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockApi } from "./mockApi";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockApi(page);
|
||||
});
|
||||
|
||||
test("validation page: issues table, distributions and reason filter", async ({ page }) => {
|
||||
const issueRequests: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/issues")) issueRequests.push(req.url());
|
||||
});
|
||||
|
||||
await page.goto("/validation");
|
||||
|
||||
// Both rejected objects are listed
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible();
|
||||
|
||||
// Result distribution
|
||||
await expect(page.getByText("Result distribution")).toBeVisible();
|
||||
|
||||
// Click a reject-reason row → URL param + filtered request
|
||||
await page.getByRole("button", { name: /manifest stale/ }).click();
|
||||
await expect(page).toHaveURL(/reason=manifest/);
|
||||
await expect.poll(() => issueRequests.some((u) => u.includes("reason=manifest"))).toBe(true);
|
||||
await expect(page.getByText("rsync://repo-b.example/ca2/broken.roa")).toBeVisible();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeHidden();
|
||||
|
||||
// Clear filters
|
||||
await page.getByRole("link", { name: "Clear filters" }).click();
|
||||
await expect(page.getByText("rsync://repo-a.example/ca1/stale.cer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("search page: grouped results and navigation", async ({ page }) => {
|
||||
await page.goto("/search?q=repo-a.example");
|
||||
await expect(page.getByText("Repositories (1)")).toBeVisible();
|
||||
await expect(page.getByText("Publication points (2)")).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /repo-a.example/ }).first().click();
|
||||
await expect(page).toHaveURL(/\/repositories\/repo-a1b2c3d4e5f6|\/publication-points\/node_/);
|
||||
});
|
||||
|
||||
test("search page: exact URI finds the object", async ({ page }) => {
|
||||
await page.goto("/search?q=rsync://repo-a.example/ca1/alice.roa");
|
||||
await expect(page.getByText("Objects (1)")).toBeVisible();
|
||||
await page.getByRole("link", { name: /alice.roa/ }).click();
|
||||
await expect(page).toHaveURL(/\/objects\/obj-roa-0001/);
|
||||
});
|
||||
|
||||
test("search page: ASN/prefix queries show the #070 capability notice", async ({ page }) => {
|
||||
await page.goto("/search?q=AS65001");
|
||||
await expect(page.getByRole("status").filter({ hasText: "feature #070" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("search page: empty result state", async ({ page }) => {
|
||||
await page.goto("/search?q=nothing-matches-this");
|
||||
await expect(page.getByText(/No results for/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("exports page: seeded job completes and offers a download", async ({ page }) => {
|
||||
const mock = await installMockApi(page);
|
||||
mock.seedJob({ status: "running" });
|
||||
|
||||
await page.goto("/exports");
|
||||
await expect(page.getByText("job-0001")).toBeVisible();
|
||||
// Job transitions to complete after the poll interval
|
||||
await expect(page.getByRole("link", { name: "Download" })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("2.0 KiB")).toBeVisible();
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.setTimeout(120_000);
|
||||
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||
|
||||
async function openFirstRepositoryObject(page: import("@playwright/test").Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||
await reposSection.locator(".stack-row-select").first().click();
|
||||
await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click();
|
||||
const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first();
|
||||
await expect(row).toBeVisible({ timeout: 70_000 });
|
||||
const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim();
|
||||
await row.getByRole("button", { name: "Open" }).click();
|
||||
await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 });
|
||||
}
|
||||
|
||||
test("supports URI search and reports raw/export workflow status", async ({ page }) => {
|
||||
const apiRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.startsWith("/api/v1")) {
|
||||
apiRequests.push(`${request.method()} ${url.pathname}${url.search}`);
|
||||
}
|
||||
});
|
||||
|
||||
await openFirstRepositoryObject(page);
|
||||
|
||||
await page.getByRole("button", { name: "Use selected URI" }).click();
|
||||
await page.getByPlaceholder("Exact URI lookup...").press("Enter");
|
||||
await expect.poll(
|
||||
() => apiRequests.some((request) => request.includes("/objects/by-uri")),
|
||||
{ timeout: 30_000 }
|
||||
).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Download raw" }).click();
|
||||
await expect.poll(
|
||||
() => apiRequests.some((request) => request.includes("/raw")),
|
||||
{ timeout: 30_000 }
|
||||
).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Export object" }).click();
|
||||
await expect(page.getByText(/Object export job|Object export failed/)).toBeVisible({ timeout: 30_000 });
|
||||
expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/") && request.includes("/exports"))).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "Export selected PP" }).click();
|
||||
await expect(page.getByText(/PP export job|PP export failed/)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-workflows.png`, fullPage: true });
|
||||
});
|
||||
Reference in New Issue
Block a user