A Playwright Reporter That Sends Structured, Interactive Slack Messages

A Playwright Reporter That Sends Structured, Interactive Slack Messages

posted 5 min read

What this is

@playwright-labs/reporter-slack is a Playwright Reporter implementation that sends formatted Slack messages when a test run ends. It handles structuring results by status, surfacing error details inline, masking sensitive environment variables, and posting via either Incoming Webhooks or the Slack Web API.

This post covers the full API.


Installation

pnpm add -D @playwright-labs/reporter-slack @playwright-labs/slack-buildkit

slack-buildkit is a peer dependency used internally and also available for writing custom templates. If you only use built-in templates, you still need it installed.


Basic configuration

// playwright.config.ts
import { defineConfig } from "@playwright/test";
import { WithOptionsTemplate } from "@playwright-labs/reporter-slack/templates";

export default defineConfig({
  reporter: [
    ["@playwright-labs/reporter-slack", {
      send: { webhook: process.env.SLACK_WEBHOOK_URL },
      template: WithOptionsTemplate,
    }],
  ],
});

The reporter:

  1. Implements onTestEnd(test, result) — accumulates [TestCase, TestResult][]
  2. Implements onEnd(result) — resolves template(result, testCases)SlackBlock[] → posts to Slack

The template option accepts a function, a SlackBlock[] array, a SlackMessage object, or an async function returning any of the above.


Three built-in templates

BaseTemplate

Minimal. Header, total/duration summary, list of failed tests (max 10 by default), optional report button.

import { BaseTemplate } from "@playwright-labs/reporter-slack/templates";

template: (result, testCases) =>
  BaseTemplate(result, testCases, {
    projectName: "My App",
    reportUrl: process.env.REPORT_URL,
  }),

Config:

type BaseTemplateOptions = {
  projectName?: string;  // default: "Playwright"
  reportUrl?: string;
  maxFailures?: number;  // default: 10
};

WithOptionsTemplate

Status-grouped with an interactive static_select element. Groups are ordered: failed → timedOut → interrupted → skipped → passed. Filter options are built dynamically — if no tests timed out, no "Timed out" option appears.

import { WithOptionsTemplate } from "@playwright-labs/reporter-slack/templates";

template: (result, testCases) =>
  WithOptionsTemplate(result, testCases, {
    projectName:   "My App",
    reportUrl:     process.env.REPORT_URL,
    maxPerStatus:  5,
    showTestNames: true,
    show: {
      passed:   false,  // omit passed group from the message
    },
  }),

Config:

type WithOptionsTemplateConfig = {
  projectName?: string;       // default: "Playwright"
  reportUrl?: string;
  maxPerStatus?: number;      // default: 10
  showTestNames?: boolean;    // default: true
  show?: {
    failed?: boolean;         // default: true
    passed?: boolean;         // default: true
    skipped?: boolean;        // default: true
    timedOut?: boolean;       // default: true
    interrupted?: boolean;    // default: true
  };
};

Overflow behavior: when testCases.length > maxPerStatus, the section ends with _… and N more_.

Error display: for failed and timedOut tests, the first line of result.errors[0].message is rendered below the test name in italics.

WithTableTemplate

Same test summary as WithOptionsTemplate, but prepends a GFM table of environment variables (rendered via Slack's markdown block type).

import { WithTableTemplate } from "@playwright-labs/reporter-slack/templates";

template: (result, testCases) =>
  WithTableTemplate(result, testCases, {
    projectName:    "My App",
    reportUrl:      process.env.REPORT_URL,
    tableTitle:     "Build Environment",
    env: {
      CI:           process.env.CI,
      BRANCH:       process.env.BRANCH,
      BUILD_ID:     process.env.BUILD_ID,
      COMMIT_SHA:   process.env.COMMIT_SHA,
      DEPLOY_ENV:   process.env.DEPLOY_ENV,
      DB_HOST:      process.env.DB_HOST,
      DB_PASSWORD:  process.env.DB_PASSWORD,  // auto-masked
      API_KEY:      process.env.API_KEY,       // auto-masked
      OPTIONAL_VAR: process.env.OPTIONAL_VAR, // omitted if undefined
    },
    showRunSummary: true,
    rowsPerChunk:   30,
  }),

Config:

type WithTableTemplateConfig = {
  projectName?: string;                        // default: "Playwright"
  reportUrl?: string;
  env: Record<string, string | undefined>;     // required; undefined values omitted
  tableTitle?: string;                         // default: "Environment"
  mask?: boolean | string[];                   // default: true
  showRunSummary?: boolean;                    // default: true
  rowsPerChunk?: number;                       // default: 30
};

rowsPerChunk controls how many rows per markdown block before the table is split. Slack's text limit per block is ~3000 characters; 30 rows is a safe conservative default.


Auto-masking

When mask: true (default), values are replaced with •••••••• for any key matching:

/token|secret|password|pass(?:word)?|credential|auth|api[_-]?key/i

Examples of keys that match: DB_PASSWORD, AUTH_TOKEN, API_KEY, SECRET_KEY, STRIPE_CREDENTIAL, OAUTH_CLIENT_SECRET.

Masking happens in the template before any data is serialized to JSON. The raw value never appears in the Slack payload.

Override options:

mask: ["CUSTOM_KEY", "INTERNAL_TOKEN"]  // explicit list — only these keys
mask: false                              // no masking

Transport configuration

Incoming Webhook

send: { webhook: "https://hooks.slack.com/services/..." }

POSTs { blocks: SlackBlock[] } to the webhook URL. No OAuth, no scopes. Channel is fixed by the webhook app configuration.

Web API (chat.postMessage)

send: {
  token:   "xoxb-...",    // bot token with chat:write scope
  channel: "#ci-reports",
}

POSTs to https://slack.com/api/chat.postMessage with Authorization: Bearer <token>. Supports all Slack API features including thread replies and channel selection at post time.


Writing a custom template

A template is a function:

type SlackTemplate = (
  result: FullResult,
  testCases: [TestCase, TestResult][],
) => SlackBlock[] | SlackMessage | Promise<SlackBlock[] | SlackMessage>;

You can return raw SlackBlock[] JSON, or use the JSX primitives from @playwright-labs/slack-buildkit/react:

/** @jsxImportSource @playwright-labs/slack-buildkit/react */
import {
  Actions, Blocks, Button, Context, Divider,
  Header, Section, Table, Td, Th, Tr,
} from "@playwright-labs/slack-buildkit/react";
import type { FullResult, TestCase, TestResult } from "@playwright/test/reporter";

export function MyTemplate(
  result: FullResult,
  testCases: [TestCase, TestResult][],
) {
  const failed = testCases.filter(([, r]) => r.status === "failed");
  const passed = testCases.filter(([, r]) => r.status === "passed");
  const emoji = result.status === "passed" ? ":white_check_mark:" : ":x:";

  return (
    <Blocks>
      <Header>{`${emoji} My App — ${result.status === "passed" ? "All clear" : "Tests failed"}`}</Header>
      <Section>{`*Total:* ${testCases.length}  •  ✅ ${passed.length}  •  ❌ ${failed.length}`}</Section>

      {failed.length > 0 && (
        <>
          <Divider />
          <Table>
            <Tr><Th>Test</Th><Th>First error</Th></Tr>
            {failed.map(([test, r]) => (
              <Tr>
                <Td>{`\`${test.titlePath().slice(1).join(" › ")}\``}</Td>
                <Td>{r.errors[0]?.message?.split("\n")[0] ?? "—"}</Td>
              </Tr>
            ))}
          </Table>
        </>
      )}

      <Divider />
      <Actions>
        <Button url={process.env.REPORT_URL!} style="primary" action_id="view_report">
          View Full Report
        </Button>
      </Actions>
      <Context>{`My App • ${new Date().toUTCString()}`}</Context>
    </Blocks>
  );
}

JSX runtime notes:

  • Activated via /** @jsxImportSource @playwright-labs/slack-buildkit/react */ or tsconfig.json
  • No React, no DOM, no reconciler — purely synchronous function calls
  • <Table>/<Tr>/<Th>/<Td> converts to a { type: "markdown" } block with GFM table syntax
  • <Blocks> flattens all children into a flat SlackBlock[]

Register the template the same way as built-ins:

template: MyTemplate,

Testing templates

Templates return plain JSON arrays, so unit tests are straightforward:

import { test, expect } from "@playwright/test";
import type { FullResult } from "@playwright/test/reporter";
import { WithTableTemplate } from "@playwright-labs/reporter-slack/templates";

const failedRun: FullResult = {
  status: "failed",
  duration: 20000,
  startTime: new Date("2025-04-28T10:00:00Z"),
};

test("sensitive values are masked", () => {
  const blocks = WithTableTemplate(failedRun, [], {
    env: { DB_PASSWORD: "supersecret", BRANCH: "main" },
  });
  const text = blocks
    .filter((b) => b.type === "markdown")
    .map((b) => (b as any).text)
    .join("\n");
  expect(text).not.toContain("supersecret");
  expect(text).toContain("••••••••");
});

test("undefined env values are omitted from table", () => {
  const blocks = WithTableTemplate(failedRun, [], {
    env: { PRESENT: "yes", ABSENT: undefined },
  });
  const text = blocks
    .filter((b) => b.type === "markdown")
    .map((b) => (b as any).text)
    .join("\n");
  expect(text).toContain("`PRESENT`");
  expect(text).not.toContain("`ABSENT`");
});

test("actions block absent when reportUrl is not set", () => {
  const blocks = WithTableTemplate(failedRun, [], { env: {} });
  expect(blocks.find((b) => b.type === "actions")).toBeUndefined();
});

test("actions block present when reportUrl is set", () => {
  const blocks = WithTableTemplate(failedRun, [], {
    env: {},
    reportUrl: "https://ci.example.com",
  });
  expect(blocks.find((b) => b.type === "actions")).toBeDefined();
});

The test suite uses Playwright's runner — no additional test framework needed.


Source

github.com/vitaliharadkou/playwright-labs

More Posts

5 Things This Playwright SQL Fixture Does So You Don't Have To

vitalicset - Apr 13

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

Angular-Aware E2E Testing: Query Components by @Input and Signals in Playwright

vitalicset - Apr 2

Merancang Backend Bisnis ISP: API Pelanggan, Paket Internet, Invoice, dan Tiket Support

Masbadar - Mar 13

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
4 comments
2 comments

Contribute meaningful comments to climb the leaderboard and earn badges!