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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@testplane/testing-library": "^1.0.5",
"commander": "^14.0.3",
"debug": "^4.3.4",
"html-reporter": "^11.10.0-rc.1",
"html-reporter": "^11.10.0-rc.2",
"testplane": "^8.44.1-rc.1",
"zod": "^3.22.4"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@modelcontextprotocol/sdk": "^1.11.2",
"@testplane/testing-library": "^1.0.5",
"commander": "^13.1.0",
"html-reporter": "^11.10.0-rc.1",
"html-reporter": "^11.10.0-rc.2",
"testplane": "^8.44.1-rc.1",
"zod": "^3.22.4"
},
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/test/server.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const EXPECTED_TOOL_NAMES = [
"close-tab",
// report tools
"test-results",
"inspect-result",
// session tools
"launch",
"attach",
Expand Down
2 changes: 1 addition & 1 deletion packages/tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"license": "ISC",
"dependencies": {
"@testplane/testing-library": "^1.0.5",
"html-reporter": "^11.10.0-rc.1",
"html-reporter": "^11.10.0-rc.2",
"lodash.escaperegexp": "^4.1.2",
"testplane": "^8.44.1-rc.1",
"zod": "^3.22.4"
Expand Down
2 changes: 2 additions & 0 deletions packages/tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { closeTab } from "./tools/close-tab.js";
import { launchBrowser, launchBrowserWithOptions } from "./tools/launch-browser.js";
import { attachToBrowser } from "./tools/attach-to-browser.js";
import { closeBrowser } from "./tools/close-browser.js";
import { inspectResult } from "./tools/inspect-result/index.js";
import { testResults } from "./tools/test-results/index.js";

// This function just ensures that every item on a type level is a Tool<something>. AFAIK the only way to do this.
Expand All @@ -40,6 +41,7 @@ export const tools = typeCheckedTools([
attachToBrowser,
closeBrowser,
testResults,
inspectResult,
Comment thread
shadowusr marked this conversation as resolved.
]);

export { launchBrowserWithOptions, ToolKind };
Expand Down
103 changes: 103 additions & 0 deletions packages/tools/src/tools/inspect-result/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { readResultsFromReport, type ReporterTestResult } from "html-reporter/experimental/sdk";
import { createErrorResponse, createSimpleResponse } from "../../responses/index.js";
import { StandaloneTool, ToolKind } from "../../types.js";
import { downloadReportIfNeeded } from "../../utils/html-report.js";
import { toTestResultView } from "../../utils/test-result-view.js";
import { inspectResultSchema } from "./schema.js";

export { inspectResultSchema } from "./schema.js";

function getInspectResultSelectionError(
results: readonly ReporterTestResult[],
args: { name: string; browser: string; attempt?: number },
): string {
const resultsWithMatchingName = results.filter(result => result.fullName === args.name);

if (!resultsWithMatchingName.length) {
return `Test with name "${args.name}" wasn't found in this report. You can check what tests are available with the "test-results" tool.`;
}

const resultsWithMatchingBrowser = resultsWithMatchingName.filter(result => result.browserId === args.browser);
if (!resultsWithMatchingBrowser.length) {
const availableBrowsers = [...new Set(resultsWithMatchingName.map(result => result.browserId))].sort();

return `Test with name "${args.name}" wasn't run in browser "${args.browser}", it was run only in: ${availableBrowsers.join(", ")}.`;
}

const availableAttempts = resultsWithMatchingBrowser.map(result => result.attempt).sort();

return `No attempt ${args.attempt} found for test "${args.name}" in browser "${args.browser}". Available attempts: ${availableAttempts.join(", ")}.`;
}

function sortAttempts<T extends ReporterTestResult>(results: readonly T[]): T[] {
return [...results].sort((left, right) => {
Comment thread
shadowusr marked this conversation as resolved.
if (left.attempt !== right.attempt) {
return left.attempt - right.attempt;
}

return (left.timestamp ?? 0) - (right.timestamp ?? 0);
});
}

function findTestResults(
results: readonly ReporterTestResult[],
args: { name: string; browser: string; attempt?: number },
): ReporterTestResult[] {
const matchingAttempts = results.filter(
result => result.fullName === args.name && result.browserId === args.browser,
);

if (!matchingAttempts.length) {
throw new Error(getInspectResultSelectionError(results, args));
}

if (args.attempt === undefined) {
return sortAttempts(matchingAttempts);
}

const matchingAttempt = matchingAttempts.find(result => result.attempt === args.attempt);

if (!matchingAttempt) {
throw new Error(getInspectResultSelectionError(results, args));
}

return [matchingAttempt];
}

const inspectResultCb: StandaloneTool<typeof inspectResultSchema>["cb"] = async args => {
try {
const reportPath = await downloadReportIfNeeded(args.report);
const results = await readResultsFromReport(reportPath);
const selectedResults = findTestResults(results, args);
const views = selectedResults.map(result =>
toTestResultView(result, [], { includeBase64: args.includeBase64 }),
);
const view = args.attempt === undefined ? views : views[0];

return createSimpleResponse(JSON.stringify(view, null, args.pretty ? 2 : 0));
} catch (error) {
console.error("Error inspecting test result from report:", error);

return createErrorResponse(
"Error inspecting test result from report",
error instanceof Error ? error : undefined,
);
}
};

export const inspectResult: StandaloneTool<typeof inspectResultSchema> = {
kind: ToolKind.Standalone,
name: "inspect-result",
description: "Read a Testplane HTML report and inspect test result attempt details as JSON",
schema: inspectResultSchema,
cb: inspectResultCb,
cli: {
positional: ["report"],
section: "Reports",
examples: [
'testplane-cli inspect-result /path/to/html-report --name "checkout submits order" --browser chrome',
'testplane-cli inspect-result /path/to/html-report --name "checkout submits order" --browser chrome --attempt 0',
'testplane-cli inspect-result /path/to/html-report --name "checkout submits order" --browser chrome --no-pretty',
],
},
};
37 changes: 37 additions & 0 deletions packages/tools/src/tools/inspect-result/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { z } from "zod";

export const inspectResultSchema = {
report: z
.string()
.min(1)
.transform(value => value.trim())
.describe("Path or URL to a Testplane HTML report directory, report HTML file, or databaseUrls.json"),
name: z
.string()
.min(1)
.transform(value => value.trim())
.describe("Full test name to inspect"),
browser: z
.string()
.min(1)
.transform(value => value.trim())
.describe("Browser id to inspect"),
attempt: z
.number()
.int()
.min(0, "--attempt must be >= 0")
.optional()
.describe(
"Attempt index to inspect. When omitted, all attempts for the selected test and browser are returned",
),
pretty: z.boolean().default(true).describe("Pretty-print JSON output. Use --no-pretty for compact JSON"),
includeBase64: z
.boolean()
.default(false)
.describe(
"Include base64 image payloads in result images and errors. Omitted by default to keep output compact",
),
};

export const inspectResultObjectSchema = z.object(inspectResultSchema);
export type InspectResultArgs = z.output<typeof inspectResultObjectSchema>;
81 changes: 7 additions & 74 deletions packages/tools/src/tools/test-results/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto";
import { mkdir, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { stripVTControlCharacters } from "node:util";
import { StandaloneTool, ToolKind } from "../../types.js";
import { createErrorResponse, createSimpleResponse } from "../../responses/index.js";
import { getImageError, getImageStateName, isMutedResult, downloadReportIfNeeded } from "../../utils/html-report.js";
import { formatDuration, formatError, formatFileSize, formatTimestamp } from "../../utils/formatters.js";
import { downloadReportIfNeeded } from "../../utils/html-report.js";
import { formatDuration, formatFileSize, formatTimestamp } from "../../utils/formatters.js";
import { stringify } from "../../utils/strings.js";
import { toTestResultView } from "../../utils/test-result-view.js";
import { filterTestResults, getResultStatusTags } from "./filters.js";
import { testResultsSchema } from "./schema.js";
import {
Expand Down Expand Up @@ -61,25 +61,6 @@ function getStatusCounts(results: readonly ReporterTestResult[]): StatusCounts {
return statusCounts;
}

function getFailedImageInfo(result: ReporterTestResult): string | null {
const failedImage = result.imagesInfo?.find(imageInfo => {
const status = imageInfo.status;

return status === "error" || status === "fail";
});

if (!failedImage) {
return null;
}

const stateName = getImageStateName(failedImage);
const state = stateName ? `state: ${stateName}` : null;
const error = formatError(getImageError(failedImage));
const message = [state, error].filter((part): part is string => part !== null).join("; ");

return message || null;
}

function formatStatusCounts(statusCounts: StatusCounts): string {
return TEST_RESULT_STATUSES.map(status => [status, statusCounts[status]] as const)
.map(([status, count]) => `${status}: ${count}`)
Expand All @@ -100,56 +81,6 @@ function formatActiveFilters(filters: FilterOptions): string | null {
return activeFilters.length ? activeFilters.join("; ") : null;
}

function getErrorLine(result: ReporterTestResult): string | null {
const status = result.status;
const shouldMentionMissingError = status === "error" || status === "fail" || isMutedResult(result);

const error = formatError(result.error) ?? getFailedImageInfo(result);

return error ?? (shouldMentionMissingError ? "No error message" : null);
}

/** Picks fields from a test result and returns a view object */
export function toTestResultView(result: ReporterTestResult, fields: TestResultField[]): TestResultView {
const view: TestResultView = {};

for (const field of fields) {
switch (field) {
case "name":
view.name = result.fullName;
break;
case "status":
view.status = isMutedResult(result) ? "muted" : result.status;
break;
case "browser":
view.browser = result.browserId;
break;
case "attempt":
view.attempt = result.attempt;
break;
case "duration":
view.duration = result.duration ?? null;
break;
case "file":
view.file = result.file ?? null;
break;
case "error": {
const errorLine = getErrorLine(result);
view.error = errorLine === null ? null : stripVTControlCharacters(errorLine);
break;
}
case "meta":
view.meta = result.meta ?? null;
break;
case "skipOrMuteReason":
view.skipOrMuteReason = result.skipReason ?? null;
break;
}
}

return view;
}

function formatTestResultField(result: TestResultView, field: TestResultField): string | null {
switch (field) {
case "skipOrMuteReason":
Expand Down Expand Up @@ -182,7 +113,7 @@ function formatTestResult(result: TestResultView, index: number, fields: readonl
.filter(Boolean)
.join(" | ");
const title = fields.includes("name") ? (result.name ?? null) : null;
const errorLine = fields.includes("error") ? result.error : null;
const errorLine = fields.includes("error") && typeof result.error === "string" ? result.error : null;
const firstLine = details || title || errorLine || "(no output fields)";

return [`${index}. ${firstLine}`, details && title ? ` ${title}` : null, errorLine ? ` ${errorLine}` : null]
Expand Down Expand Up @@ -298,7 +229,9 @@ const testResultsCb: StandaloneTool<typeof testResultsSchema>["cb"] = async args
const matchedResults = filterTestResults(finalResults, args);

const counts = getTestResultsCounts(results.length, finalResults, matchedResults);
const resultViews = matchedResults.map(result => toTestResultView(result, args.fields));
const resultViews = matchedResults.map(result =>
toTestResultView(result, args.fields, { errorFormat: "line" }),
);

if (args.saveJson) {
const savedReport = await saveTestResultsJsonReport(args.report, counts, args.fields, resultViews);
Expand Down
39 changes: 13 additions & 26 deletions packages/tools/src/tools/test-results/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type { ReporterTestResult } from "html-reporter/experimental/sdk";
import type { TestResultField, TestResultView } from "../../utils/test-result-view.js";

export {
TEST_RESULT_VIEW_FIELDS as TEST_RESULT_FIELDS,
DETAILED_TEST_RESULT_FIELDS,
} from "../../utils/test-result-view.js";
export type {
DetailedTestResultField,
ReporterImageInfo,
TestResultField,
TestResultView,
TestStepView,
} from "../../utils/test-result-view.js";

export const TEST_RESULT_STATUSES = ["passed", "failed", "muted", "retried", "skipped"] as const;
export const TEST_RESULT_FIELDS = [
"status",
"browser",
"attempt",
"duration",
"file",
"name",
"error",
"meta",
"skipOrMuteReason",
] as const;
export const DEFAULT_TEST_RESULT_FIELDS = [
"status",
"browser",
Expand All @@ -22,9 +23,7 @@ export const DEFAULT_TEST_RESULT_FIELDS = [
"error",
] as const;

export type ReporterImageInfo = NonNullable<ReporterTestResult["imagesInfo"]>[number];
export type TestResultStatus = (typeof TEST_RESULT_STATUSES)[number];
export type TestResultField = (typeof TEST_RESULT_FIELDS)[number];
export type StatusCounts = Record<TestResultStatus, number>;

export interface RegexFilter {
Expand Down Expand Up @@ -65,18 +64,6 @@ export interface PaginationOptions {
offset: number;
}

export interface TestResultView {
status?: string;
browser?: string;
attempt?: number;
duration?: number | null;
file?: string | null;
name?: string;
error?: string | null;
meta?: unknown | null;
skipOrMuteReason?: string | null;
}

export interface TestResultsCounts {
totalTests: number;
totalAttempts: number;
Expand Down
2 changes: 1 addition & 1 deletion packages/tools/src/utils/html-report.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { downloadReport, type ReporterTestResult } from "html-reporter/experimental/sdk";
import { DEFAULT_REMOTE_RESOURCE_CACHE_ROOT, resolveCachedRemoteResource } from "./remote-resource-cache.js";
import type { ReporterImageInfo } from "../tools/test-results/types.js";
import type { ReporterImageInfo } from "./test-result-view.js";

const DATABASE_URLS_FILE = "databaseUrls.json";

Expand Down
Loading
Loading