Skip to content
Open
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
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ After the test completes, you can run `npx playwright show-report` to see a deta

- **Core Execution** — `runSteps()` and `runUserFlow()` for flexible test orchestration in natural language, with smart caching and auto-healing
- **Multi-Model Assertion Engine** — Consensus-based validation using Claude and Gemini, with an arbiter model to resolve disagreements
- **Redis-Based Step Caching** — Cache-first execution with AI fallback and automatic self-healing when cached steps fail
- **Pluggable Step Caching** — Cache-first execution with AI fallback and automatic self-healing. Supports Redis, file-based, or custom cache backends.
- **Configurable AI Models** — 8 dedicated model slots for step execution, assertions, extraction, and more
- **AI Gateway Support** — Route requests through Vercel AI Gateway, OpenRouter, Cloudflare AI Gateway, or connect directly to provider SDKs
- **Dynamic Placeholders** — Inject values at runtime with `{{run.*}}`, `{{global.*}}`, `{{data.*}}`, and `{{email.*}}` expressions for repeatable and data-driven tests
Expand Down Expand Up @@ -184,6 +184,8 @@ configure({

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CACHE_PROVIDER` | No | - | Cache backend: `redis`, `file`, or `none`. Falls back to Redis if `REDIS_URL` is set. |
| `CACHE_DIR` | No | `.passmark-cache` | Directory for file-based cache (when `CACHE_PROVIDER=file`) |
| `REDIS_URL` | No | - | Redis connection URL for step caching and global state |
| `ANTHROPIC_API_KEY` | Yes | - | Anthropic API key for Claude models |
| `GOOGLE_GENERATIVE_AI_API_KEY` | Yes | - | Google API key for Gemini models |
Expand Down Expand Up @@ -212,7 +214,35 @@ All models are configurable via `configure({ ai: { models: { ... } } })`:

## Caching

Passmark caches successful step actions in Redis. On subsequent runs, cached steps execute directly without AI calls, dramatically reducing latency and cost.
Passmark caches successful step actions so that subsequent runs execute directly without AI calls, dramatically reducing latency and cost. The cache backend is pluggable — choose between Redis, file-based, or no caching at all.

### Cache Providers

Set the `CACHE_PROVIDER` environment variable to select a backend:

| Provider | `CACHE_PROVIDER` | Additional Config | Description |
|----------|-------------------|-------------------|-------------|
| **Redis** | `redis` | `REDIS_URL` | Uses Redis via ioredis. Default when `REDIS_URL` is set. |
| **File** | `file` | `CACHE_DIR` (optional, defaults to `.passmark-cache`) | JSON files on disk. No external dependencies — great for local development and CI. |
| **None** | `none` | — | Disables caching entirely. Every step uses AI execution. |

For backwards compatibility, if `CACHE_PROVIDER` is not set, Passmark will use Redis when `REDIS_URL` is present, otherwise caching is disabled.

### Custom Cache Store

You can implement a custom cache backend by conforming to the `CacheStore` interface:

```typescript
import { CacheStore } from "passmark";

interface CacheStore {
hgetall(key: string): Promise<Record<string, string>>;
hset(key: string, values: Record<string, string>): Promise<void>;
expire(key: string, seconds: number): Promise<void>;
}
```

### Caching Behavior

- Steps are cached by `userFlow` + `step.description`
- Set `bypassCache: true` on individual steps or the entire run to force AI execution
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/data-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../redis", () => ({
redis: { hgetall: vi.fn(), hset: vi.fn(), expire: vi.fn() },
vi.mock("../cache", () => ({
cache: { hgetall: vi.fn(), hset: vi.fn(), expire: vi.fn() },
}));

vi.mock("../email", () => ({
Expand Down
28 changes: 14 additions & 14 deletions src/__tests__/integration/run-steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock instrumentation (imported as side effect)
vi.mock("../../instrumentation", () => ({ axiomEnabled: false }));

// Mock Redis
vi.mock("../../redis", () => ({
redis: {
// Mock Cache
vi.mock("../../cache", () => ({
cache: {
hgetall: vi.fn().mockResolvedValue({}),
hset: vi.fn().mockResolvedValue("OK"),
expire: vi.fn().mockResolvedValue(1),
hset: vi.fn().mockResolvedValue(undefined),
expire: vi.fn().mockResolvedValue(undefined),
},
}));

Expand Down Expand Up @@ -89,7 +89,7 @@ vi.mock("../../utils/secure-script-runner", () => ({

import { runSteps } from "../../index";
import { resetConfig } from "../../config";
import { redis } from "../../redis";
import { cache } from "../../cache";
import { generateText } from "ai";
import type { Page } from "@playwright/test";
import type { Step } from "../../types";
Expand Down Expand Up @@ -121,8 +121,8 @@ describe("runSteps", () => {
beforeEach(() => {
vi.clearAllMocks();
resetConfig();
// Reset redis mock to default empty
vi.mocked(redis!.hgetall).mockResolvedValue({});
// Reset cache mock to default empty
vi.mocked(cache!.hgetall).mockResolvedValue({});
});

it("executes a simple step", async () => {
Expand Down Expand Up @@ -198,8 +198,8 @@ describe("runSteps", () => {
const page = createMockPage();
const steps: Step[] = [{ description: "Click submit" }];

// Mock redis to return cached step data
vi.mocked(redis!.hgetall).mockResolvedValue({
// Mock cache to return cached step data
vi.mocked(cache!.hgetall).mockResolvedValue({
locator: 'getByRole("button", { name: "Submit" })',
action: "click",
description: "Submit button",
Expand All @@ -220,8 +220,8 @@ describe("runSteps", () => {
const page = createMockPage();
const steps: Step[] = [{ description: "Click submit" }];

// Mock redis to return cached step data
vi.mocked(redis!.hgetall).mockResolvedValue({
// Mock cache to return cached step data
vi.mocked(cache!.hgetall).mockResolvedValue({
locator: 'getByRole("button", { name: "Submit" })',
action: "click",
description: "Submit button",
Expand Down Expand Up @@ -291,8 +291,8 @@ describe("runSteps", () => {
it("bypasses cache for individual step when step.bypassCache is true", async () => {
const page = createMockPage();

// Mock redis to return cached data
vi.mocked(redis!.hgetall).mockResolvedValue({
// Mock cache to return cached data
vi.mocked(cache!.hgetall).mockResolvedValue({
locator: 'getByRole("button", { name: "Go" })',
action: "click",
description: "Go button",
Expand Down
160 changes: 160 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { logger } from "./logger";

// =============================================================================
// Cache Store Interface
// =============================================================================

/**
* Interface for a hash-based cache store.
* Implementations must support hash get/set and key expiration.
*/
export interface CacheStore {
hgetall(key: string): Promise<Record<string, string>>;
hset(key: string, values: Record<string, string>): Promise<void>;
expire(key: string, seconds: number): Promise<void>;
}

// =============================================================================
// Redis Store
// =============================================================================

class RedisStore implements CacheStore {
private client: import("ioredis").default;

constructor(url: string) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Redis = require("ioredis") as typeof import("ioredis").default;
this.client = new Redis(url);
}

async hgetall(key: string): Promise<Record<string, string>> {
return this.client.hgetall(key);
}

async hset(key: string, values: Record<string, string>): Promise<void> {
await this.client.hset(key, values);
}

async expire(key: string, seconds: number): Promise<void> {
await this.client.expire(key, seconds);
}
}

// =============================================================================
// File Store
// =============================================================================

import * as fs from "fs";
import * as path from "path";

class FileStore implements CacheStore {
private dir: string;

constructor(dir: string) {
this.dir = dir;
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}

private filePath(key: string): string {
// Encode key to a safe filename
const safeKey = encodeURIComponent(key);
return path.join(this.dir, `${safeKey}.json`);
}

private read(key: string): { data: Record<string, string>; expiresAt?: number } | null {
const fp = this.filePath(key);
if (!fs.existsSync(fp)) return null;

try {
const raw = JSON.parse(fs.readFileSync(fp, "utf-8"));

// Check expiration
if (raw.expiresAt && Date.now() > raw.expiresAt) {
fs.unlinkSync(fp);
return null;
}

return raw;
} catch {
return null;
}
}

private write(key: string, entry: { data: Record<string, string>; expiresAt?: number }): void {
const fp = this.filePath(key);
fs.writeFileSync(fp, JSON.stringify(entry), "utf-8");
}

async hgetall(key: string): Promise<Record<string, string>> {
const entry = this.read(key);
return entry?.data ?? {};
}

async hset(key: string, values: Record<string, string>): Promise<void> {
const existing = this.read(key);
const merged = { ...(existing?.data ?? {}), ...values };
this.write(key, { data: merged, expiresAt: existing?.expiresAt });
}

async expire(key: string, seconds: number): Promise<void> {
const existing = this.read(key);
if (!existing) return;
this.write(key, { ...existing, expiresAt: Date.now() + seconds * 1000 });
}
}

// =============================================================================
// Factory
// =============================================================================

/**
* Creates the cache store based on environment variables.
*
* CACHE_PROVIDER selects the backend:
* - "redis" (default when REDIS_URL is set): uses Redis via ioredis
* - "file": uses JSON files on disk at CACHE_DIR (defaults to .passmark-cache)
* - "none": disables caching entirely
*
* For backwards compatibility, if CACHE_PROVIDER is not set:
* - If REDIS_URL is set → uses Redis
* - Otherwise → caching is disabled (null)
*/
function createCacheStore(): CacheStore | null {
const provider = process.env.CACHE_PROVIDER?.toLowerCase();

if (provider === "none") {
logger.warn("Cache provider set to 'none'. Caching is disabled.");
return null;
}

if (provider === "file") {
const dir = process.env.CACHE_DIR || ".passmark-cache";
logger.info(`Using file-based cache at: ${dir}`);
return new FileStore(dir);
}

if (provider === "redis" || (!provider && process.env.REDIS_URL)) {
if (!process.env.REDIS_URL) {
logger.warn("CACHE_PROVIDER is 'redis' but REDIS_URL is not set. Caching is disabled.");
return null;
}
logger.info("Using Redis cache.");
return new RedisStore(process.env.REDIS_URL);
}

if (provider) {
logger.warn(`Unknown CACHE_PROVIDER '${provider}'. Caching is disabled.`);
return null;
}

// No CACHE_PROVIDER and no REDIS_URL
logger.warn(
"No cache provider configured. Set CACHE_PROVIDER=redis|file|none or REDIS_URL. " +
"Step caching, global placeholders, and project data are disabled.",
);
return null;
}

export const cache: CacheStore | null = createCacheStore();
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ export const MAX_RETRIES = 3;
// Thinking budgets (tokens)
export const THINKING_BUDGET_DEFAULT = 1024;

// Redis
// Cache
export const GLOBAL_VALUES_TTL_SECONDS = 86400;
Loading