|
| 1 | +import assert from "node:assert/strict"; |
| 2 | +import { createServer } from "node:http"; |
| 3 | +import * as path from "node:path"; |
| 4 | +import { fileURLToPath } from "node:url"; |
| 5 | +import * as Effect from "effect/Effect"; |
| 6 | +import * as Schema from "effect/Schema"; |
| 7 | +import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 8 | +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 9 | +import { NodeRuntime, NodeServices } from "@effect/platform-node"; |
| 10 | +import { TestReport } from "@expect/shared/models"; |
| 11 | +import { Console, Layer, Stream } from "effect"; |
| 12 | +import { Reporter, GitRepoRoot } from "@expect/supervisor"; |
| 13 | +import { RrVideo } from "@expect/browser"; |
| 14 | +import * as fs from "node:fs"; |
| 15 | + |
| 16 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 17 | + |
| 18 | +const WEBSITE_OUT_DIR = join(__dirname, "../../apps/website/out"); |
| 19 | +const CLI_PATH = join(__dirname, "../../apps/cli/dist/index.js"); |
| 20 | +const TIMEOUT_MS = 900_000; |
| 21 | +const TEST_CASE = process.env.TEST_CASE ?? "website"; |
| 22 | +const ARTIFACTS_DIR = `/tmp/test-artifacts/${TEST_CASE}`; |
| 23 | + |
| 24 | +const WEBSITE_INSTRUCTION = `Test the expect.dev marketing website at http://localhost:3000. |
| 25 | +Run each item below as a separate test step. If a step fails, record the failure with evidence but continue to the next step. |
| 26 | +
|
| 27 | +1. Homepage loads — navigate to http://localhost:3000, verify the page renders with a hero section and install commands visible. |
| 28 | +2. View demo — click the "View demo" button/link on the homepage, verify it navigates to /replay?demo=true and the replay player loads with demo content. |
| 29 | +3. Replay controls — on the /replay?demo=true page, verify play/pause button works, speed selector is present, and step list is visible. |
| 30 | +4. Copy button — go back to the homepage, click the copy button next to the install command, verify the clipboard contains the expected command text. |
| 31 | +5. Theme toggle — click the theme toggle to switch to dark mode, verify the background color changes. Switch back to light mode. |
| 32 | +6. Footer links — verify the footer contains links to GitHub (github.com/millionco/expect) and X (x.com/aidenybai) with target="_blank". |
| 33 | +7. Legal pages — navigate to /terms, /privacy, and /security in sequence. Verify each page loads with text content. |
| 34 | +8. Mobile viewport — resize the viewport to 375x812, navigate to the homepage, verify the page renders without horizontal scrollbar and key content is visible.`; |
| 35 | + |
| 36 | +const DOGFOOD_INSTRUCTION = `Visit http://localhost:7681 which shows the expect CLI running in a web terminal (xterm.js). \ |
| 37 | +This is an interactive terminal UI for a browser testing tool. Test the FULL workflow: \ |
| 38 | +(1) Verify the TUI renders with a logo/header and input prompt. \ |
| 39 | +(2) Type a test instruction like 'test the homepage at http://localhost:3000' into the input field and submit it. \ |
| 40 | +(3) The CLI should generate a test plan — verify the plan review screen appears with test steps. \ |
| 41 | +(4) Approve the plan (press Enter or the confirm key). \ |
| 42 | +(5) Watch the execution progress — verify steps are being executed with status updates. \ |
| 43 | +(6) Wait for completion and verify the results screen shows pass/fail outcomes. \ |
| 44 | +Note: This is a terminal rendered in xterm.js. Type by clicking the terminal and using keyboard input.`; |
| 45 | + |
| 46 | +const TEST_INSTRUCTION = TEST_CASE === "dogfood" ? DOGFOOD_INSTRUCTION : WEBSITE_INSTRUCTION; |
| 47 | + |
| 48 | +const layerServer = Layer.effectDiscard( |
| 49 | + Effect.acquireRelease( |
| 50 | + Effect.promise(() => |
| 51 | + import("serve-handler").then(({ default: handler }) => { |
| 52 | + const server = createServer((req, res) => |
| 53 | + handler(req, res, { public: WEBSITE_OUT_DIR }) |
| 54 | + ); |
| 55 | + return new Promise<typeof server>((resolve) => |
| 56 | + server.listen(3000, () => resolve(server)) |
| 57 | + ); |
| 58 | + }) |
| 59 | + ), |
| 60 | + (server) => |
| 61 | + Effect.promise( |
| 62 | + () => new Promise<void>((resolve) => server.close(() => resolve())) |
| 63 | + ) |
| 64 | + ) |
| 65 | +); |
| 66 | + |
| 67 | +const main = Effect.gen(function* () { |
| 68 | + const reporter = yield* Reporter; |
| 69 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 70 | + const stdout = yield* ChildProcess.make("node", [ |
| 71 | + CLI_PATH, |
| 72 | + "--ci", |
| 73 | + "--verbose", |
| 74 | + "--reporter", |
| 75 | + "json", |
| 76 | + "--timeout", |
| 77 | + String(TIMEOUT_MS), |
| 78 | + "--test-id", |
| 79 | + TEST_CASE, |
| 80 | + "-m", |
| 81 | + TEST_INSTRUCTION, |
| 82 | + ]).pipe( |
| 83 | + spawner.streamString, |
| 84 | + Stream.tap((line) => Console.log(line)), |
| 85 | + Stream.runCollect, |
| 86 | + Effect.map((lines) => lines.join("\n")) |
| 87 | + ); |
| 88 | + |
| 89 | + const report = yield* Schema.decodeEffect(TestReport.json)(stdout); |
| 90 | + |
| 91 | + const resultsDir = "/tmp/expect-results"; |
| 92 | + fs.mkdirSync(resultsDir, { recursive: true }); |
| 93 | + fs.writeFileSync(join(resultsDir, `${TEST_CASE}.json`), stdout); |
| 94 | + |
| 95 | + console.log(`\nTest Report: ${report.status}`); |
| 96 | + console.log(`Title: ${report.title}`); |
| 97 | + console.log(`Summary: ${report.summary}`); |
| 98 | + console.log(`Steps: ${report.steps.length}`); |
| 99 | + for (const step of report.steps) { |
| 100 | + const icon = |
| 101 | + step.status === "passed" ? "✓" : step.status === "failed" ? "✗" : "⏭"; |
| 102 | + console.log(` ${icon} ${step.title} (${step.status})`); |
| 103 | + } |
| 104 | + |
| 105 | + console.log("\nAssertions:"); |
| 106 | + assert.ok( |
| 107 | + report.status === "passed" || report.status === "failed", |
| 108 | + "status is passed or failed" |
| 109 | + ); |
| 110 | + assert.ok(report.title.length > 0, "title is non-empty"); |
| 111 | + assert.ok(report.summary.length > 0, "summary is non-empty"); |
| 112 | + assert.ok(report.steps.length > 0, "has at least one step"); |
| 113 | + |
| 114 | + fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); |
| 115 | + yield* reporter.exportVideo(report, { |
| 116 | + exportPathOverride: join(ARTIFACTS_DIR, `${TEST_CASE}.mp4`), |
| 117 | + }).pipe( |
| 118 | + Effect.catchTag("RrVideoConvertError", (error) => { |
| 119 | + console.log(`Video conversion failed (non-fatal): ${error.message}`); |
| 120 | + return Effect.void; |
| 121 | + }), |
| 122 | + Effect.catchTag("PlatformError", (error) => { |
| 123 | + console.log(`Video conversion failed (non-fatal): ${error.message}`); |
| 124 | + return Effect.void; |
| 125 | + }) |
| 126 | + ); |
| 127 | + |
| 128 | + yield* report.assertSuccess(); |
| 129 | +}).pipe( |
| 130 | + Effect.provide(Reporter.layer), |
| 131 | + Effect.provide(RrVideo.layer), |
| 132 | + Effect.provide(Layer.succeed(GitRepoRoot, process.cwd())), |
| 133 | + Effect.provide(NodeServices.layer) |
| 134 | +); |
| 135 | + |
| 136 | +const mainWithServer = TEST_CASE === "website" |
| 137 | + ? main.pipe(Effect.provide(layerServer)) |
| 138 | + : main; |
| 139 | + |
| 140 | +NodeRuntime.runMain(mainWithServer); |
0 commit comments