Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/application/app/pages/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ const state = reactive({
const loading = ref(false);
const error = ref('');

// Where to land after signing in: a same-origin path handed over by a link
// that needed a session first (the reporter's per-failure links), else home.
function redirectTarget(): string {
const target = route.query.redirect;
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
}

// Fresh instance with auth enabled and zero users: the login form can never
// succeed, so show a first-admin setup form instead (mirrors the documented
// `POST /api/auth/setup` curl flow, but reachable from the UI).
Expand Down Expand Up @@ -124,7 +131,7 @@ async function handleLogin() {
title: 'Login successful',
color: 'success',
});
router.push('/');
router.push(redirectTarget());
} catch (err: unknown) {
const errorMessage =
err && typeof err === 'object' && 'data' in err ? (err.data as { message?: string })?.message : undefined;
Expand Down
134 changes: 134 additions & 0 deletions apps/application/server/routes/test-runs/[id]/locate.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { H3Event } from 'h3';
import { and, eq } from 'drizzle-orm';
import { getDatabase } from '../../../database';
import { testCases, testRuns, testRunsCases } from '../../../database/schema';
import { getCurrentUser, isAuthEnabled } from '../../../utils/auth';
import { canAccessProject } from '../../../utils/project-access';
import { FAILED_STATUS_KEYS } from '#shared/utils/test-counts';

/**
* Resolve an execution from what the reporter knows before the server assigns
* ids — the run id plus the test's spec file, title, retry and Playwright
* project — and redirect to its page. The reporter prints these links the
* moment a test fails, in streaming and batch mode alike, so the link has to
* be computable without waiting for an execution id.
*
* `retry` and `browser` are preferences, not filters: when the exact attempt
* is missing (a retry that was never persisted, a project name that changed)
* the closest execution of the same test still resolves rather than 404ing.
*/
export default eventHandler(async (event) => {
setResponseHeader(event, 'Cache-Control', 'no-store');
setResponseHeader(event, 'X-Robots-Tag', 'noindex, nofollow');

const runId = Number.parseInt(getRouterParam(event, 'id') ?? '', 10);
const query = getQuery(event);
const file = queryString(query.file);
const title = queryString(query.title);
const retry = queryString(query.retry);
const browser = queryString(query.browser);
const wantedRetry = retry !== null && /^\d+$/.test(retry) ? Number(retry) : null;

if (!Number.isInteger(runId) || runId <= 0 || !file || !title) {
return notFoundPage(event, 'This link is missing the run, spec file or test title it should point at.', null);
}

if (isAuthEnabled(event) && !(await getCurrentUser(event))) {
const url = getRequestURL(event);
return sendRedirect(event, `/login?redirect=${encodeURIComponent(url.pathname + url.search)}`);
}

const db = await getDatabase();
const [run] = await db.select({ projectId: testRuns.projectId }).from(testRuns).where(eq(testRuns.id, runId));
const user = isAuthEnabled(event) ? await getCurrentUser(event) : null;
if (!run || !(await canAccessProject(db, user, run.projectId))) {
return notFoundPage(event, `Run #${runId} does not exist, or it was deleted.`, null);
}

const candidates = await db
.select({
id: testRunsCases.id,
status: testRunsCases.status,
retries: testRunsCases.retries,
browserName: testRunsCases.browserName,
})
.from(testRunsCases)
.innerJoin(testCases, eq(testRunsCases.testCaseId, testCases.id))
.where(and(eq(testRunsCases.testRunId, runId), eq(testCases.filePath, file), eq(testCases.title, title)));

const match = pickExecution(candidates, wantedRetry, browser);
if (!match) {
return notFoundPage(event, `Run #${runId} has no execution of "${title}" in ${file}.`, runId);
}

return sendRedirect(event, `/test-run-cases/${match.id}`);
});

interface Candidate {
id: number;
status: string;
retries: number | null;
browserName: string | null;
}

/**
* The best execution for the requested attempt: the exact project and retry
* when persisted, otherwise the failing one, otherwise the latest attempt.
*/
export function pickExecution(candidates: Candidate[], retry: number | null, browser: string | null): Candidate | null {
const failed = new Set<string>(FAILED_STATUS_KEYS);
const score = (c: Candidate): number[] => [
browser !== null && c.browserName === browser ? 1 : 0,
retry !== null && (c.retries ?? 0) === retry ? 1 : 0,
failed.has(c.status) ? 1 : 0,
c.retries ?? 0,
c.id,
];
let best: Candidate | null = null;
let bestScore: number[] = [];
for (const candidate of candidates) {
const s = score(candidate);
if (!best || compareScores(s, bestScore) > 0) {
best = candidate;
bestScore = s;
}
}
return best;
}

function compareScores(a: number[], b: number[]): number {
for (let i = 0; i < a.length; i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}

function queryString(value: unknown): string | null {
if (Array.isArray(value)) value = value[0];
return typeof value === 'string' && value !== '' ? value : null;
}

function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

/** A readable 404 for someone who followed a link from a terminal or a CI log. */
function notFoundPage(event: H3Event, reason: string, runId: number | null): string {
setResponseStatus(event, 404);
setResponseHeader(event, 'Content-Type', 'text/html; charset=utf-8');
const runLink = runId ? `<p><a href="/test-runs/${runId}">Open run #${runId}</a> and find the test there.</p>` : '';
return (
'<!doctype html><html><head><meta charset="utf-8"><title>Test not found</title></head>' +
'<body style="font-family: system-ui, sans-serif; margin: 4rem auto; max-width: 36rem; text-align: center;">' +
'<h1>Piwi could not find that test</h1>' +
`<p>${escapeHtml(reason)}</p>` +
`<p>The run may not have finished uploading yet, or its results may have been pruned by retention.</p>${runLink}` +
'</body></html>'
);
}
12 changes: 9 additions & 3 deletions apps/application/server/utils/email.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
import { renderEventSubject, notificationTargetPath } from '#shared/notification-events';
import { renderEventSubject, notificationTargetPath, failureTargetPath } from '#shared/notification-events';
import type {
NotificationEvent,
NotificationPayload,
Expand Down Expand Up @@ -173,6 +173,12 @@ export function renderTestEmail(to: string): { html: string; text: string } {
return { html, text };
}

/** Where a failing test links to: its execution, else its history, else the run. */
function failureUrl(failure: TopFailure, runUrl: string): string {
const path = failureTargetPath(failure);
return path ? `${siteUrl()}${path}` : runUrl;
}

export function renderRunNotificationEmail(opts: {
projectName: string;
runId: number;
Expand All @@ -191,7 +197,7 @@ export function renderRunNotificationEmail(opts: {
if (failures.length > 0) {
const rows = failures
.map((f) => {
const caseUrl = f.testCaseId ? `${siteUrl()}/test-cases/${f.testCaseId}` : url;
const caseUrl = failureUrl(f, url);
const title = escapeHtml(f.title);
const titleHtml = `<a href="${caseUrl}" style="color:#18181b;font-weight:600;text-decoration:none;">${title}</a>`;
const loc = f.filePath ? `<div style="color:#a1a1aa;font-size:12px;">${escapeHtml(f.filePath)}</div>` : '';
Expand All @@ -204,7 +210,7 @@ export function renderRunNotificationEmail(opts: {
failuresHtml = `<ul style="margin:0 0 24px;padding:0;">${rows}</ul>`;
failuresText = failures
.map((f) => {
const caseUrl = f.testCaseId ? `${siteUrl()}/test-cases/${f.testCaseId}` : url;
const caseUrl = failureUrl(f, url);
const loc = f.filePath ? ` (${f.filePath})` : '';
const excerpt = f.errorExcerpt ? `\n ${f.errorExcerpt.replace(/\n/g, '\n ')}` : '';
return `- ${f.title}${loc}\n ${caseUrl}${excerpt}`;
Expand Down
5 changes: 3 additions & 2 deletions apps/application/server/utils/notifications/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
RunFinishedPayload,
ClusterNewPayload,
} from '#shared/notification-events';
import { renderEventSubject, notificationTargetPath } from '#shared/notification-events';
import { renderEventSubject, notificationTargetPath, failureTargetPath } from '#shared/notification-events';
import type { LibSQLDatabase } from 'drizzle-orm/libsql';

const MAX_ATTEMPTS = 5;
Expand Down Expand Up @@ -120,7 +120,8 @@ async function sendToSlack(config: Record<string, unknown>, event: NotificationE
if (event.startsWith('run.')) {
const p = payload as RunFinishedPayload;
for (const f of p.topFailures ?? []) {
const link = f.testCaseId ? `<${base}/test-cases/${f.testCaseId}|${f.title}>` : f.title;
const path = failureTargetPath(f);
const link = path ? `<${base}${path}|${f.title}>` : f.title;
const excerpt = f.errorExcerpt ? `\n\`\`\`${slackExcerpt(f.errorExcerpt)}\`\`\`` : '';
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `• *${link}*${excerpt}` } });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { projects, testRuns, failureClusters, testRunsCases, testCases } from '.
import { emitNotification } from './emit';
import {
buildTopFailures,
truncateExcerpt,
errorExcerpt,
computePerfBaseline,
TOP_FAILURES_LIMIT,
PERF_BASELINE_RUNS,
Expand Down Expand Up @@ -153,7 +153,7 @@ export async function emitRunNotifications(db: DbClient, runId: number): Promise
projectName: project.label || project.name,
signature: cluster.signature,
runId,
sampleErrorExcerpt: truncateExcerpt(cluster.sampleError),
sampleErrorExcerpt: errorExcerpt(cluster.sampleError),
affectedCases: affected.length,
});
}
Expand Down
13 changes: 3 additions & 10 deletions apps/application/server/utils/scm/pr-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
buildPrComment,
DEFAULT_PR_FEEDBACK,
PR_COMMENT_MARKER,
PR_EXCERPT_MAX,
PR_FEEDBACK_KEY,
resolvePrFeedbackSettings,
type PrFailureEntry,
Expand All @@ -39,6 +40,7 @@ import type { VerifiedFix } from '../fix-verification';
import type { RunMetadata } from '../run-json-types';
import type { DbClient } from '../../database';
import type { FilterDetails } from '#shared/types';
import { errorExcerpt } from '#shared/notification-events';

/** Read the resolved settings, falling back to the (disabled) defaults. */
export async function getPrFeedbackSettings(db: DbClient): Promise<PrFeedbackSettings> {
Expand All @@ -51,15 +53,6 @@ const FAIL_STATUSES = ['failed', 'timedOut', 'timedout'];
/** Recent default-branch runs scanned to decide whether a failure is already flaky there. */
const DEFAULT_BRANCH_FLAKY_RUNS = 50;

/** First line of an error, capped so a comment stays readable. */
function excerpt(error: string | null): string | null {
if (!error) return null;
const firstLine = error.split('\n').find((line) => line.trim().length > 0);
if (!firstLine) return null;
const trimmed = firstLine.trim();
return trimmed.length > 200 ? `${trimmed.slice(0, 199)}…` : trimmed;
}

/** What the dashboard is reachable at, for the links inside the comment. */
function resolveSiteUrl(): string | null {
const configured = process.env.PIWI_SITE_URL?.trim();
Expand Down Expand Up @@ -123,7 +116,7 @@ async function buildFailureEntries(
return {
title: row.title,
filePath: row.filePath,
errorExcerpt: excerpt(row.error),
errorExcerpt: errorExcerpt(row.error, PR_EXCERPT_MAX) ?? null,
executionId: row.id,
clusterId: row.failureClusterId,
clusterSignature: row.failureClusterId ? (clusterSignatures.get(row.failureClusterId) ?? null) : null,
Expand Down
3 changes: 2 additions & 1 deletion apps/application/shared/error-fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ function classifyError(text: string): ErrorType {
* Cut the error down to its message head: everything before the Playwright
* call log and the JS stack trace, capped at 5 non-empty lines so long
* element dumps (strict-mode violations) don't destabilize the fingerprint.
* Notifications and pull-request comments quote the same head.
*/
function extractMessageHead(text: string): string {
export function extractMessageHead(text: string): string {
let head = text;
const callLogIdx = head.indexOf('\nCall log:');
if (callLogIdx !== -1) head = head.slice(0, callLogIdx);
Expand Down
60 changes: 54 additions & 6 deletions apps/application/shared/notification-events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { extractMessageHead, stripAnsi } from '#shared/error-fingerprint';

/** All notification event keys supported by the subscription system. */
export const NOTIFICATION_EVENTS = [
'run.finished',
Expand Down Expand Up @@ -70,18 +72,63 @@ export interface ClusterNewPayload {
}

/**
* Trim, strip ANSI colour codes, and cap an error message so it can be embedded
* in a notification payload (and rendered in email/Slack) without bloating it.
* Trim, strip ANSI colour codes, and cap a text so it can be embedded in a
* notification payload (and rendered in email/Slack) without bloating it.
* Returns undefined for empty input.
*/
export function truncateExcerpt(text?: string | null, max: number = ERROR_EXCERPT_MAX): string | undefined {
if (!text) return undefined;
const esc = String.fromCharCode(27);
const clean = text.replace(new RegExp(esc + '\\[[0-9;]*m', 'g'), '').trim();
const clean = stripAnsi(text).trim();
if (!clean) return undefined;
return clean.length > max ? clean.slice(0, max).trimEnd() + '…' : clean;
}

/** A message head that says nothing but that a timeout elapsed. */
const BARE_TIMEOUT_RE = /^(?:\w*Error:\s*)?(?:[\w.]+:\s*)?(?:Test )?[Tt]imeout(?: of)? \d+m?s exceeded\.?$/;
/** Call-log lines that say where Playwright was when the timeout hit. */
const CALL_LOG_STATE_RE = /^\s*-\s*((?:waiting for|locator resolved to)\b.*)$/;

/**
* The part of an error worth quoting outward — in a notification, a
* pull-request comment, a digest: the message head (the lines before the
* Playwright call log and the stack trace, at most five), with the last
* `waiting for …` / `locator resolved to …` call-log line appended when the
* head is only a bare timeout. ANSI codes are stripped and the result is
* capped at `max` characters. Returns undefined for empty input.
*/
export function errorExcerpt(text?: string | null, max: number = ERROR_EXCERPT_MAX): string | undefined {
if (!text) return undefined;
const clean = stripAnsi(text).trim();
if (!clean) return undefined;
let head = extractMessageHead(clean) || clean.split('\n')[0]!.trim();
if (BARE_TIMEOUT_RE.test(head)) {
const state = lastCallLogState(clean);
if (state) head = `${head}\n${state}`;
}
return truncateExcerpt(head, max);
}

function lastCallLogState(text: string): string | null {
const start = text.indexOf('Call log:');
if (start === -1) return null;
let last: string | null = null;
for (const line of text.slice(start).split('\n')) {
const match = CALL_LOG_STATE_RE.exec(line);
if (match) last = match[1]!.trim();
}
return last;
}

/**
* Dashboard path for one failing test: the execution with its evidence when
* the payload carries one, otherwise the test's history page.
*/
export function failureTargetPath(failure: Pick<TopFailure, 'testCaseId' | 'executionId'>): string | null {
if (failure.executionId != null) return `/test-run-cases/${failure.executionId}`;
if (failure.testCaseId != null) return `/test-cases/${failure.testCaseId}`;
return null;
}

/** Raw failing-case row shape consumed by {@link buildTopFailures}. */
export interface TopFailureInput {
title: string;
Expand All @@ -93,15 +140,16 @@ export interface TopFailureInput {

/**
* Map raw failing-case rows to the compact {@link TopFailure} shape embedded in
* run notifications: capped to `limit` entries with truncated error excerpts.
* run notifications: capped to `limit` entries, each error cut to its
* {@link errorExcerpt}.
*/
export function buildTopFailures(rows: TopFailureInput[], limit: number = TOP_FAILURES_LIMIT): TopFailure[] {
return rows.slice(0, limit).map((r) => {
const failure: TopFailure = { title: r.title };
if (r.filePath) failure.filePath = r.filePath;
if (r.testCaseId != null) failure.testCaseId = r.testCaseId;
if (r.executionId != null) failure.executionId = r.executionId;
const excerpt = truncateExcerpt(r.error);
const excerpt = errorExcerpt(r.error);
if (excerpt) failure.errorExcerpt = excerpt;
return failure;
});
Expand Down
2 changes: 2 additions & 0 deletions apps/application/shared/pr-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ export interface PrSummaryInput {
// ── Rendering ────────────────────────────────────────────────────────────────

const MAX_LISTED = 5;
/** Max characters of an error excerpt quoted in the pull-request comment. */
export const PR_EXCERPT_MAX = 200;

/** Escape the characters that would break out of a markdown table cell. */
function escapeCell(text: string): string {
Expand Down
1 change: 1 addition & 0 deletions apps/application/shared/test-project-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const PROJECT = {
REVOKED_KEY: 'revoked-key-test',
RUN_COMPARE: 'run-compare',
RUN_LABEL: 'run-label-test',
RUN_LOCATE: 'run-locate-test',
RUN_PAGE_FILTERS: 'run-page-filters-test',
RUN_SUMMARY_TEST: 'run-summary-test',
SHARDING_TEST: 'sharding-test',
Expand Down
Loading
Loading