@cloudflare/telescope is a TypeScript browser performance testing library and CLI built on Playwright. It launches real browsers (Chrome, Chrome Beta, Canary, Firefox, Safari, Edge), collects HAR files, Web Vitals, and performance metrics, and produces HTML reports.
Key subdirectories:
packages/telescope/— Core library and CLI (TypeScript, Playwright, Vitest)src/— TypeScript source compiled todist/__tests__/— Vitest integration tests (excluded from tsconfig)tests/— Static test fixtures (HTML, CSS, images) used by integration testssupport/— Browser support files (e.g., Firefox defaultuser.jspreferences)processors/— Standalone post-processing report generator (included in main tsconfig)
packages/telescope-web/— Separate Astro + Cloudflare Workers web app (fully excluded from root tooling)
This is an npm workspaces monorepo. node_modules is installed at the repo root and shared across all packages. Install dependencies from the root:
npm installCommands can be run from the repo root (targeting a specific workspace) or from within each package directory.
# All packages
npm run build --workspaces
npm run test --workspaces
npm run lint --workspaces
# Telescope package (shortcuts)
npm run test:telescope # build + vitest run
npm run test:telescope:ci # CI mode (Firefox only)
npm run coverage:telescope # vitest run --coverage
# Specific package (explicit workspace flag)
npm run build -w packages/telescope
npm run test -w packages/telescope
npm run lint -w packages/telescope
npm run build -w packages/telescope-web
npm run test -w packages/telescope-webnpm run build -w packages/telescope
npx . -u https://example.comThe root package.json bin field points to packages/telescope/dist/src/cli.js. Build the telescope package first — npx . requires compiled output.
See packages/telescope/AGENTS.md and packages/telescope-web/AGENTS.md for the full list of per-package commands.
- Target: ES2022, Module: NodeNext, ModuleResolution: NodeNext
- Strict mode: fully enabled (
strict: true) - Additional strict flags:
noUnusedLocals,noUnusedParameters,noImplicitReturns,noFallthroughCasesInSwitch - All local imports must use
.jsextensions even when importing.tssource files — required bymodule: NodeNext
// Correct
import { log } from "./helpers.js";
import type { LaunchOptions } from "./types.js";
// Wrong — will fail to resolve
import { log } from "./helpers";- Use named imports as the default; use default imports only for packages that export a default (e.g.,
playwright,path,ejs) - Use
import type { ... }for all type-only imports — enforced by ESLint (@typescript-eslint/consistent-type-imports: error) - No path aliases — use relative paths (
./types.js,../src/index.js) - Group: external packages first, then internal relative imports
import { Command, Option } from "commander";
import playwright from "playwright";
import type { BrowserContext } from "playwright";
import type { LaunchOptions, TestResult } from "./types.js";
import { log, generateTestID } from "./helpers.js";- Single quotes for strings
- 2-space indentation
- Trailing commas everywhere (arrays, objects, function parameters)
- No parentheses on single-argument arrow functions:
x => x * 2not(x) => x * 2 - EJS templates formatted via
prettier-plugin-ejs
| Entity | Convention | Example |
|---|---|---|
| Files | camelCase.ts |
testRunner.ts, defaultOptions.ts |
| Classes | PascalCase |
TestRunner, BrowserConfig, ChromeRunner |
| Interfaces | PascalCase |
LaunchOptions, NetworkProfile, TestPaths |
| Type aliases | PascalCase |
BrowserName, ConnectionType, TestResult |
| Functions | camelCase |
launchTest(), normalizeCLIConfig(), generateTestID() |
| Constants | UPPER_SNAKE_CASE |
DEFAULT_OPTIONS |
| Class methods/properties | camelCase |
setupTest(), browserConfig, consoleMessages |
| Unused parameters | prefix with _ |
_cleanupError, _unusedArg |
- All shared types live in
src/types.ts— the single source of truth. Add new types there. - Use
interfacefor objects with multiple properties;typefor unions, primitives, and derived types - Use
Record<K, V>for maps,Partial<T>for optional shapes,Pick<T, K>for property subsets - Use utility types to derive rather than duplicate:
Exclude<ConnectionType, false>,Parameters<BrowserContext['addCookies']>[0][number] - Avoid
any—@typescript-eslint/no-explicit-any: erroris enforced. Useunknownand narrow withinstanceofor type guards - Type assertion on caught errors:
(error as Error).message(nounknown-based helper utility currently in use) - Augment
Windowinsrc/types.tsviadeclare global { interface Window { ... } }
Three patterns in use:
1. Public API never throws — discriminated union result:
export async function launchTest(options: LaunchOptions): Promise<TestResult> {
try {
return await executeTest(options);
} catch (error) {
return { success: false, error: (error as Error).message };
}
}Callers narrow with if (result.success) { ... }.
The Telescope class in src/index.ts wraps launchTest for OOP-style usage:
const telescope = new Telescope({
url: "https://example.com",
browser: "chrome",
});
const result = await telescope.run();2. Cleanup-even-on-error (resource management):
try {
await Runner.setupTest();
await Runner.doNavigation();
} catch (error) {
try {
await Runner.cleanup();
} catch (_cleanupError) {
/* Ignore */
}
throw error;
}3. Non-fatal file I/O errors — log and continue:
try {
writeFileSync(path, JSON.stringify(data), "utf8");
} catch (err) {
console.error("Error writing file: " + err);
}Validation errors throw directly: throw new Error('Invalid browser name').
- One class per file; file name matches the class name in
camelCase - Named exports for everything — do not use
export defaultin new code. (The one legacy exception isexport default function browserAgent()insrc/index.ts, used bysrc/cli.ts.) - Use inheritance sparingly:
ChromeRunner extends TestRunneris the only hierarchy — subclass only to add browser-specific protocol logic (CDP), not general behavior - Factory functions select the right class:
getRunner(options, browserConfig)returnsTestRunner(which may be aChromeRunnersubtype) - Use
DEFAULT_OPTIONSinsrc/defaultOptions.tsas the canonical source for defaults — do not hardcode defaults in Commander.js options and the programmatic API separately
Add JSDoc to all public API functions and class methods:
/**
* Launches a browser performance test.
* @param options - Test configuration
* @returns Discriminated union: success result with testId, or failure with error message
* @throws Never — all errors are caught and returned as { success: false }
*/
export async function launchTest(options: LaunchOptions): Promise<TestResult> { ... }- Framework: Vitest v4 + @vitest/coverage-v8 (ESM mode)
- Test files:
__tests__/*.test.tsonly — helper utilities go in__tests__/helpers.ts - Test style: Integration tests that launch real browsers. Unit tests are rare.
- Tests use
describe.each(browsers)to run across the browser matrix - In CI (
process.env.CI === 'true'), only Firefox runs; locally, all 6 browsers run. Set theBROWSERSenv var to override (e.g.BROWSERS=chrome,firefox) - Shared test helpers (
retrieveHAR,retrieveConfig,retrieveMetrics) live in__tests__/helpers.ts - Use
msw/node(setupServer,http,HttpResponse) to mock HTTP endpoints (e.g., upload APIs) - Tests that invoke the CLI use
spawnSync('node', ['dist/src/cli.js', ...])— always build first
// Typical parameterized test
import { launchTest } from "../src/index.js";
import { describe, it, expect, beforeAll } from "vitest";
import { BrowserConfig } from "../src/browsers.js";
import type { SuccessfulTestResult } from "../src/types.js";
import { retrieveHAR } from "./helpers.js";
const browsers = BrowserConfig.getBrowsers();
describe.each(browsers)("Feature: %s", (browser) => {
let result: SuccessfulTestResult;
beforeAll(async () => {
const testResult = await launchTest({
url: "https://example.com",
browser,
});
if (!testResult.success) throw new Error(testResult.error);
result = testResult;
}, 120000); // always set explicit timeout for browser tests
it("produces a HAR file", () => {
expect(retrieveHAR(result.testId)).toBeTruthy();
});
});CLI options and programmatic inputs are validated using Zod schemas:
- Schemas are defined in
src/schemas.ts— includesCookieSchema,HeadersSchema,AuthSchema,FirefoxPrefsSchema,DelaySchema,PositiveIntSchema,PositiveFloatSchema, etc. - Validation utilities are in
src/validation.ts:parseCLIOption(flagName, jsonString, schema)— parses JSON strings from CLI args and validates against schemaparseUnknown(flagName, data, schema)— validates already-parsed dataparseWithSchema(schema, value, flag)— coerces and validates raw CLI stringsformatZodError(error)— formats Zod validation errors for CLI output
Validation is integrated into the CLI via Commander.js argParser functions that throw InvalidArgumentError on failure.
The project includes Docker support for containerized testing:
- Dockerfile — Single-stage build with Playwright dependencies
- docker-compose.yml — Service orchestration
Build and run:
docker build -t telescope .
docker run --rm -v $(pwd)/results:/app/results telescope --url https://example.comPackage versions for Playwright packages must match, or Playwright may install the wrong browsers or fail in other documented or unpredictable ways.
playwrightpackageplaywright-webkitpackage@playwright/testpackagemcr.microsoft.com/playwright:v<X.Y.Z>-nobleCI test container in .github/workflows/test.yml
packages/telescope-web/is a fully independent project — do not touch its files when working on the core library. It has its ownpackage.jsonand is excluded frompackages/telescope/tooling configs.- Core library commands can be run from the repo root (
npm run build -w packages/telescope) or from withinpackages/telescope/directly. - Processors (
processors/generate.ts) are compiled with the main build but run as a standalone script:node dist/processors/generate.js <results-dir>. Guarded withif (process.argv[1] === __filename). - Runtime path resolution:
testRunner.tsdetects whether it is running from compileddist/or source viaisCompiledDist = currentDir.includes('/dist/')— preserve this logic when modifying path-dependent code. - Template files are copied post-
tscin thebuildscript — if you add new.ejstemplates undersrc/templates/, update thebuildscript accordingly.