Skip to main content

Testing Guide

Opal Editor uses two testing approaches: custom unit tests with the TestSuite framework and end-to-end tests with Playwright. This guide covers both testing methodologies.

Testing Frameworks

Unit Tests: TestSuite

Opal Editor uses a custom lightweight testing framework located at ~/workspace/source/src/lib/tests/TestSuite.ts:1-127. This framework provides:
  • Simple, readable test syntax
  • Async test support
  • Rich assertion methods
  • Clear console output

End-to-End Tests: Playwright

Playwright tests ensure the entire application works correctly in real browsers. Configuration is at ~/workspace/source/playwright.config.ts:1-52.

Running Tests

Run All E2E Tests

npm test
This runs Playwright tests across all configured browsers (Chromium, Firefox, WebKit).

Run Tests with UI

npm run test:ui
Opens the Playwright Test UI for interactive test debugging.

Run Tests in Headed Mode

npm run test:headed
Runs tests with visible browser windows.

Debug Tests

npm run test:debug
Runs tests in debug mode with Playwright Inspector.

Run Unit Tests

npm run test:unit
# or
npm run test:build
Runs the custom TestSuite unit tests (currently for build strategies).

Playwright Configuration

The Playwright config (~/workspace/source/playwright.config.ts:1-52) includes:
{
  testDir: "./playwright-tests",
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry"
  }
}
Key settings:
  • Test directory: playwright-tests/
  • Base URL: http://localhost:3000
  • Parallel execution: Enabled by default
  • Retries: 2 retries on CI, 0 locally
  • Trace collection: On first retry (for debugging)
  • Browsers: Chromium, Firefox, WebKit

Web Server

Playwright automatically starts the dev server before running tests:
webServer: {
  command: "npm run dev:slow",
  url: "http://localhost:3000",
  reuseExistingServer: !process.env.CI
}

Writing E2E Tests

Test File Location

Place E2E tests in the playwright-tests/ directory:
playwright-tests/
└── workspace-creation.spec.ts

Test Structure

Example from ~/workspace/source/playwright-tests/workspace-creation.spec.ts:1-112:
import { expect, test } from "@playwright/test";

test.describe("Workspace Creation Flow", () => {
  test("should create a new workspace and add a markdown file", async ({ page }) => {
    // Navigate to the home page
    await page.goto("/");

    // Wait for elements to be visible
    await expect(page.getByRole("link", { name: "new workspace" })).toBeVisible();

    // Interact with the page
    await page.getByRole("link", { name: "new workspace" }).click();

    // Assert URL changes
    await expect(page).toHaveURL(/.*\/newWorkspace/);

    // Verify UI elements
    await expect(page.getByRole("dialog", { name: "New Workspace" })).toBeVisible();
  });
});

Best Practices for E2E Tests

1. Use semantic selectors
// Good: Use role-based selectors
await page.getByRole("button", { name: "Create" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("my-workspace");

// Avoid: CSS selectors when possible
await page.locator(".create-button").click();
2. Wait for visibility
// Always wait for elements before interacting
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByRole("button", { name: "Submit" }).click();
3. Use descriptive test names
test("should create workspace with custom name", async ({ page }) => {
  // Clear test description of what's being tested
});
4. Test user flows, not implementation
// Test what users do
test("user can create and rename a file", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("button", { name: "New File" }).click();
  // ... test the flow
});

Writing Unit Tests

Test File Location

Place unit tests next to the code they test with a .test.ts suffix:
src/
└── services/
    └── build/
        ├── BuildStrategies.ts
        └── BuildStrategies.test.ts

TestSuite API

The custom TestSuite framework (~/workspace/source/src/lib/tests/TestSuite.ts:1-127) provides: Creating a test suite:
import { TestSuite } from "@/lib/tests/TestSuite";

const suite = new TestSuite("My Feature Tests");
Adding tests:
suite.test("should do something", async () => {
  const result = await myFunction();
  suite.assert(result === true, "Result should be true");
});
Assertion methods:
// Basic assertion
suite.assert(condition, "Error message");

// Equality
suite.assertEqual(actual, expected, "Values should match");

// Deep equality (JSON comparison)
suite.assertDeepEqual(actualObj, expectedObj, "Objects should match");

// Type checking
suite.assertType(value, "string", "Should be a string");

// Instance checking
suite.assertInstanceOf(obj, MyClass, "Should be MyClass instance");

// Exception testing
suite.assertThrows(() => {
  throwingFunction();
}, "Expected error message");

// Async exception testing
await suite.assertThrowsAsync(async () => {
  await asyncThrowingFunction();
}, "Expected error message");
Running the suite:
// At the end of your test file
if (import.meta.url === `file://${process.argv[1]}`) {
  suite.run().catch(console.error);
}

export { suite as MyTestSuite };

Example Unit Test

From ~/workspace/source/src/services/build/BuildStrategies.test.ts:50-69:
import { TestSuite } from "@/lib/tests/TestSuite";
import { BuildDAO } from "@/data/dao/BuildDAO";
import { Disk } from "@/data/disk/Disk";

const suite = new TestSuite("Build Strategies");

suite.test("Freeform: should process markdown to HTML", async () => {
  const sourceDisk = await createMockDisk({
    "/index.md": "# Hello World\n\nThis is a test.",
    "/global.css": "body { margin: 0; }",
  });

  const build = await createMockBuild("freeform", sourceDisk);
  const runner = FreeformBuildRunner.Show({ build });

  await runner.run();

  const outputDisk = build.getOutputDisk();
  const files = await outputDisk.listFiles(absPath("/"));

  suite.assert(files.some((f) => f.endsWith("index.html")), "Should generate index.html");
  suite.assert(files.some((f) => f.endsWith("global.css")), "Should copy global.css");

  const htmlContent = await outputDisk.readFile(absPath("/index.html"));
  suite.assert(htmlContent.includes("<h1>Hello World</h1>"), "Should convert markdown to HTML");
});

if (import.meta.url === `file://${process.argv[1]}`) {
  suite.run().catch(console.error);
}

Unit Test Best Practices

1. Test one thing per test
// Good
suite.test("should convert markdown to HTML", async () => {
  // Test only markdown conversion
});

suite.test("should copy static assets", async () => {
  // Test only asset copying
});

// Avoid testing multiple unrelated behaviors in one test
2. Use descriptive test names
// Good
suite.test("should throw error when file does not exist", async () => {
  // ...
});

// Avoid vague names
suite.test("test 1", async () => {
  // ...
});
3. Create helper functions for setup
// Create reusable setup helpers
async function createMockDisk(files: Record<string, string>): Promise<Disk> {
  const diskDAO = DiskDAO.CreateNew("memory");
  await diskDAO.save();
  const disk = await Disk.FromDAO(diskDAO);

  for (const [path, content] of Object.entries(files)) {
    await disk.writeFile(absPath(path), content);
  }

  return disk;
}
4. Use meaningful assertions
// Good: Specific error message
suite.assert(
  htmlContent.includes("<h1>Hello World</h1>"),
  "Should convert markdown heading to HTML h1 tag"
);

// Avoid: Generic messages
suite.assert(result === true, "Failed");

Test Coverage

Currently tested areas:
  • E2E Tests:
    • Workspace creation flow
    • File creation and management
    • UI interactions
  • Unit Tests:
    • Build strategies (Freeform, Eleventy)
    • Template processing (Nunjucks, Liquid, Mustache, EJS)
    • File system operations
    • Event emitters
    • Promise polyfills

Areas Needing Tests

Consider adding tests for:
  • Git operations (clone, commit, push, pull)
  • Publishing workflows (Netlify, Vercel, Cloudflare)
  • Editor features (markdown editing, image upload)
  • Search functionality
  • Theme switching
  • Import/export features

Debugging Tests

Playwright Debugging

1. Use Playwright Inspector
npm run test:debug
2. Add debugging statements
test("debug example", async ({ page }) => {
  await page.pause(); // Pauses execution
  console.log(await page.textContent("h1")); // Log content
});
3. View traces After a test failure, open the HTML report:
npx playwright show-report

Unit Test Debugging

1. Add console logs
suite.test("debug example", async () => {
  const result = await myFunction();
  console.log("Result:", result);
  suite.assertEqual(result, expected);
});
2. Run single test file
tsx src/path/to/your-test.test.ts

Continuous Integration

On CI environments:
  • Tests retry up to 2 times on failure
  • Tests run sequentially (not in parallel)
  • Dev server must start fresh (no reuse)
  • HTML reports are generated for debugging

Test Organization

File Naming

  • E2E tests: *.spec.ts in playwright-tests/
  • Unit tests: *.test.ts next to source files

Test Grouping

// E2E: Use test.describe for grouping
test.describe("Workspace Features", () => {
  test("should create workspace", async ({ page }) => {});
  test("should delete workspace", async ({ page }) => {});
});

// Unit: Create separate suite instances or group with comments
const suite = new TestSuite("Feature Group");

// Group 1: Basic functionality
suite.test("test 1", async () => {});
suite.test("test 2", async () => {});

// Group 2: Edge cases
suite.test("test 3", async () => {});

Contributing Tests

When contributing code:
  1. Add E2E tests for new user-facing features
  2. Add unit tests for complex business logic
  3. Ensure all tests pass before submitting PR
  4. Update this guide if introducing new testing patterns
See the Contributing Guide for more information.

Resources

Build docs developers (and LLMs) love